后台登录改造:自建登录页 + Cookie 会话,弃用 basic auth 弹窗

- editor-api 新增 /admin/login|session|logout:HttpOnly Cookie 会话(落盘持久化)、
  登录失败限速(5 次锁 5 分钟)、鉴权改为「X-Editor-Token 或 会话」双通道
- 登录页重做:品牌标识、图标输入框、密码可见切换、柔光背景,深浅色适配
- 前端以 /admin/session 探测部署模式:国内机走本机会话,CF 版走原令牌流程
- nginx:/admin/ 与 /api/v2/editor/ 去掉 basic auth 与令牌注入,登录凭据只存 Cookie
- bootstrap.sh 同步 ADMIN_USER/ADMIN_PASS,保持一键迁移能力
This commit is contained in:
zqlit committed 2026-10-04 23:40:23 +08:00
1 parent 96751b8143
commit 9f586d625b
5 files changed
+484 -39

No files matched your search

+168 -16
View File
@@ -8,7 +8,9 @@
// ============================================================ 基础工具
const API = location.origin + '/api/v2';
const RSS_API = 'https://api.200181.xyz';
// 订阅服务跑在 Cloudflare Worker 上。后台若部署在国内机,同源请求会由 nginx
// 反代过去;部署在 Worker 上时 location.origin 就是 api.200181.xyz,行为一致。
const RSS_API = location.origin;
const LS_T = 'atk.token';
const LS_U = 'atk.user';
const LS_RSS = 'atk.rss.token'; // 订阅口令
@@ -50,6 +52,9 @@ const state = {
sites: [],
site: '',
me: null,
// 国内机模式:后台静态页部署在国内机(writeapi.usj.cc),编辑器接口同机直连,
// 登录态是本机签发的 Cookie 会话,整条链路不过 Cloudflare。
direct: false,
pending: 0,
c: { q: '', type: 'all', offset: 0, limit: 20, total: 0, denom: 1, rows: [], busy: false },
p: { offset: 0, limit: 30, total: 0 },
@@ -96,22 +101,47 @@ const ICONS = {
let TOKEN = localStorage.getItem(LS_T) || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* 带重试的请求。
* GET 是幂等的,网络抖一下(浏览器报 Failed to fetch / ERR_CONNECTION_*)
* 不该让用户看到「打开失败」——换个连接重试一两次基本就好了。
* 非 GET 一律不重试:写操作重放可能造成重复写入。
*/
async function api(path, opt) {
opt = opt || {};
const headers = {};
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
const method = (opt.method || 'GET').toUpperCase();
const tries = opt.retries != null ? opt.retries : (method === 'GET' ? 3 : 1);
let res;
try {
res = await fetch(API + path, {
method: opt.method || 'GET',
headers,
body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined,
signal: opt.signal,
});
} catch (e) {
throw new Error('网络不可达 —— 检查域名/网络后重试');
let res = null;
let lastErr = null;
for (let i = 0; i < tries; i++) {
if (i) await sleep(i === 1 ? 350 : 900);
const headers = {};
if (opt.body !== undefined) headers['Content-Type'] = 'application/json';
if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN;
try {
res = await fetch(API + path, {
method,
headers,
body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined,
signal: opt.signal,
});
break;
} catch (e) {
res = null;
lastErr = e;
// 主动取消(AbortError)不是网络问题,别重试
if (e && e.name === 'AbortError') break;
}
}
if (!res) {
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
const why = offline ? '本机网络已断开' : (lastErr && lastErr.message ? lastErr.message : '请求被中断');
throw new Error('网络不可达(' + why + ',已重试 ' + tries + ' 次)—— 检查网络后重试');
}
const txt = await res.text();
@@ -208,6 +238,19 @@ function confirmBox(title, text, onYes) {
function showGate() {
$('#app').hidden = true;
$('#gate').hidden = false;
// 国内机模式下登录的是本机 Cookie 会话,订阅口令(走 Cloudflare)在这里没意义
const rssField = $('#lgRssField');
if (rssField) rssField.hidden = !!state.direct;
const foot = $('#lgFoot');
if (foot) {
foot.textContent = state.direct
? '编辑器走国内机 · 不经过 Cloudflare'
: '优世界 · 评论与订阅统一管理';
}
const pass = $('#lgPass');
if (pass) pass.value = '';
setTimeout(() => { const i = $('#lgUser'); if (i) i.focus(); }, 60);
}
@@ -221,6 +264,10 @@ function showApp() {
}
function logout(msg) {
// 国内机模式登录态在 Cookie 里,得让后端删掉会话,否则刷新又进去了
if (state.direct) {
fetch(API + '/admin/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
}
TOKEN = '';
state.me = null;
localStorage.removeItem(LS_T);
@@ -243,9 +290,44 @@ async function doLogin(email, password) {
return state.me;
}
/**
* 国内机模式的登录:账号密码发给同机的 editor-api,换一个 HttpOnly Cookie。
* 全程不出境,也不碰 Cloudflare 的 D1 用户表。
*/
async function directLogin(user, password) {
let res;
try {
res = await fetch(API + '/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ user, password }),
});
} catch (e) {
throw new Error('连不上本机编辑器服务(' + (e.message || '网络错误') + ')');
}
const d = await res.json().catch(() => null);
if (!res.ok || !d || !d.ok) {
throw new Error((d && (d.error || d.msg)) || ('登录失败(HTTP ' + res.status + ')'));
}
state.me = d.user || { name: user, is_admin: true };
return state.me;
}
// ============================================================ 骨架
function renderTabs() {
// 直连模式只放「文章编辑」——其它模块(评论/设置/订阅)必须走 Cloudflare 的 D1,
// 在国内访问那一跳很慢,单独留一个入口按需登录。
if (state.direct) {
$('#tabs').innerHTML =
'<button class="tab' + (state.tab === 'posts' ? ' on' : '') + '" data-act="tab" data-id="posts">'
+ (ICONS.posts || '') + '<span>文章编辑</span></button>' +
'<div class="nav-sep"></div>' +
'<div class="nav-group">需要 Cloudflare</div>' +
'<button class="tab" data-act="relogin">' + I('<path d="M12 3v9"/><path d="M7 7.5a7 7 0 1 0 10 0"/>') + '<span>登录完整后台</span></button>';
return;
}
// 分组渲染:group 为空的(仪表盘)单独在顶部;组间加分隔线
let html = '';
let lastGroup = null;
@@ -1197,8 +1279,13 @@ async function openPost(id) {
} catch (e) {
if (state.tab !== 'posts') return;
$('#view').innerHTML = head('打开失败', '', esc(id)) +
'<div class="empty"><b>' + esc(e.message) + '</b></div>' +
'<div style="margin-top:14px"><button class="btn" data-act="po-back">返回列表</button></div>';
'<div class="empty"><b>' + esc(e.message) + '</b>' +
'<p class="po-note" style="margin-top:10px">文章还在服务器上没动过,直接重试即可;连续失败多半是本机到 Cloudflare 这一跳不稳。</p>' +
'</div>' +
'<div style="margin-top:14px">' +
'<button class="btn primary" data-act="po-open" data-id="' + esc(id) + '">重试</button> ' +
'<button class="btn" data-act="po-back">返回列表</button>' +
'</div>';
return;
}
state.ed = {
@@ -1719,6 +1806,17 @@ document.addEventListener('click', async (e) => {
if (act === 'modal-close') { closeModal(); return; }
if (act === 'menu') { toggleSidebar(); return; }
if (act === 'theme') { toggleTheme(); return; }
if (act === 'peek-pass') {
// 登录页的「显示密码」:手机上输长密码很容易打错,看得见才好排查
const input = $('#lgPass');
const on = input.type === 'password';
input.type = on ? 'text' : 'password';
el.setAttribute('aria-pressed', String(on));
el.setAttribute('aria-label', on ? '隐藏密码' : '显示密码');
el.style.color = on ? 'var(--accent)' : '';
input.focus();
return;
}
if (act === 'tab') {
const target = el.dataset.id;
if (target === 'posts' && state.tab === 'posts' && state.ed) {
@@ -1739,6 +1837,14 @@ document.addEventListener('click', async (e) => {
confirmBox('退出登录', '确定要退出当前会话吗?', () => logout());
return;
}
if (act === 'relogin') {
// 从国内机模式切回完整后台:评论/订阅仍在 Cloudflare,得走那边的会话
state.direct = false;
$('#app').hidden = true;
showGate();
setTimeout(() => toast('完整后台需经 Cloudflare 登录,国内访问这一跳可能较慢'), 200);
return;
}
// --- 文章编辑
if (act === 'po-reload') { el.dataset.busy = '1'; try { await loadPosts(); } finally { delete el.dataset.busy; } return; }
@@ -1869,6 +1975,13 @@ $('#loginForm').addEventListener('submit', async (e) => {
err.hidden = true;
btn.dataset.busy = '1';
try {
// 国内机模式:换的是本机 Cookie 会话,登录成功直接进文章编辑
if (state.direct) {
await directLogin($('#lgUser').value.trim(), $('#lgPass').value);
enterDirect();
return;
}
await doLogin($('#lgUser').value.trim(), $('#lgPass').value);
state.me = state.me || {};
if (state.me.is_admin === false) throw new Error('该账号不是管理员');
@@ -1944,7 +2057,46 @@ window.addEventListener('beforeunload', (e) => {
if (state.ed && state.ed.dirty) { e.preventDefault(); e.returnValue = ''; }
});
/**
* 问一句「本机后台的登录态还在不在」。
* 只有 editor-api 会回 { mode:'direct' };Cloudflare 版后台没这条路由,
* 回的不是这个形状 —— 用它来区分两种部署,比猜域名可靠。
*/
async function probeSession() {
try {
const r = await fetch(API + '/admin/session', { credentials: 'same-origin' });
const d = await r.json().catch(() => null);
if (!d || d.mode !== 'direct') return null;
return d;
} catch (e) { return null; }
}
/** 国内机模式:本机会话已就绪,直接进文章编辑(不经过 Cloudflare) */
function enterDirect() {
state.direct = true;
if (!state.me) state.me = { name: 'admin', is_admin: true };
$('#gate').hidden = true;
$('#app').hidden = false;
$('#who').textContent = state.me.name || 'admin';
$('#footR').textContent = '编辑器走国内机 · 不经过 Cloudflare';
go('posts');
}
(async function boot() {
// ① 本机后台(国内机部署):先看会话,未登录就亮自己的登录页
const sess = await probeSession();
if (sess) {
state.direct = true;
if (sess.ok) {
state.me = sess.user || { name: 'admin', is_admin: true };
enterDirect();
} else {
showGate();
}
return;
}
// ② Cloudflare 版后台:沿用原来的令牌流程
if (!TOKEN) { showGate(); return; }
try {
// /user 未登录时返回 200 + {user:null},所以要显式判 is_login