后台登录改造:自建登录页 + 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:
1 parent
96751b8143
commit
9f586d625b
5 files changed
+484
-39
No files matched your search
@@ -174,18 +174,100 @@ select{appearance:none;cursor:pointer;padding-right:28px;
|
||||
|
||||
/* ---------------------------------------------------------- 登录 */
|
||||
|
||||
.gate{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--bg)}
|
||||
.login-card{
|
||||
width:100%;max-width:340px;padding:32px;
|
||||
background:var(--card);
|
||||
border:1px solid var(--line);border-radius:16px;
|
||||
box-shadow:var(--shadow-pop);
|
||||
/* 背景不做成"一张白纸",用两层柔光拉开纵深 —— 纯 CSS,零额外请求。
|
||||
国内机版的后台是直连的,页面本身秒开,这里不引入任何图片。 */
|
||||
.gate{
|
||||
position:relative;min-height:100vh;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
padding:24px;background:var(--bg);overflow:hidden;
|
||||
}
|
||||
.login-card h1{font-size:19px;color:var(--accent)}
|
||||
.login-card .sub{margin:3px 0 26px;font-size:12px;color:var(--muted)}
|
||||
.login-card form{margin-top:0}
|
||||
.login-card .btn{margin-top:6px}
|
||||
.login-card .hint{margin:18px 0 0;font-size:11px;line-height:1.7;color:var(--muted);text-align:center}
|
||||
.gate::before,.gate::after{
|
||||
content:'';position:absolute;pointer-events:none;border-radius:50%;
|
||||
}
|
||||
.gate::before{
|
||||
width:560px;height:560px;left:50%;top:-200px;transform:translateX(-50%);
|
||||
background:radial-gradient(circle,var(--accent-soft),transparent 68%);
|
||||
}
|
||||
.gate::after{
|
||||
width:440px;height:440px;right:-150px;bottom:-170px;
|
||||
background:radial-gradient(circle,var(--accent-soft),transparent 72%);
|
||||
}
|
||||
|
||||
.login-card{
|
||||
position:relative;z-index:1;
|
||||
width:100%;max-width:372px;padding:34px 32px 26px;
|
||||
background:var(--card);
|
||||
border:1px solid var(--line);border-radius:18px;
|
||||
box-shadow:0 1px 2px rgba(24,48,36,.04),0 18px 46px -20px rgba(24,48,36,.30);
|
||||
}
|
||||
|
||||
/* 品牌行 —— 复用侧栏的「优」字标,视觉上前后统一 */
|
||||
.login-brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||
.lb-mark{
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
width:38px;height:38px;flex:none;border-radius:11px;
|
||||
font-size:18px;font-weight:700;color:var(--on-accent);
|
||||
background:linear-gradient(145deg,var(--accent),var(--accent-hover));
|
||||
box-shadow:0 6px 14px -7px var(--accent);
|
||||
}
|
||||
.lb-text{display:flex;flex-direction:column;line-height:1.4;min-width:0}
|
||||
.lb-text strong{font-size:14px;font-weight:600;color:var(--text)}
|
||||
.lb-text span{font-size:11px;color:var(--muted);letter-spacing:.02em}
|
||||
|
||||
.login-head{margin-bottom:22px}
|
||||
.login-head h1{font-size:21px;font-weight:600;letter-spacing:-.01em;color:var(--text)}
|
||||
.login-head .sub{margin:6px 0 0;font-size:12px;line-height:1.6;color:var(--muted)}
|
||||
|
||||
.login-card .field{margin-bottom:15px}
|
||||
.login-card .btn{margin-top:4px}
|
||||
.login-card .alert{margin-bottom:14px}
|
||||
|
||||
/* 带图标的输入框 */
|
||||
.lg-input{position:relative;display:flex;align-items:center}
|
||||
.lg-input>svg{
|
||||
position:absolute;left:12px;width:15px;height:15px;
|
||||
color:var(--muted);pointer-events:none;transition:color .15s;
|
||||
}
|
||||
.lg-input input{
|
||||
height:42px;padding:0 12px 0 36px;font-size:13px;
|
||||
background:var(--bg);border-radius:9px;
|
||||
}
|
||||
.lg-input input:focus{border-color:var(--accent);background:var(--card);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||
.lg-input:focus-within>svg{color:var(--accent)}
|
||||
/* 有「显示密码」按钮的那一行,右边多让出 40px */
|
||||
.lg-input.has-peek input{padding-right:40px}
|
||||
|
||||
.lg-peek{
|
||||
position:absolute;right:6px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
width:30px;height:30px;padding:0;
|
||||
color:var(--muted);background:transparent;
|
||||
border:0;border-radius:7px;cursor:pointer;
|
||||
transition:color .15s,background-color .15s;
|
||||
}
|
||||
.lg-peek:hover{color:var(--accent);background:var(--accent-soft)}
|
||||
.lg-peek svg{width:15px;height:15px}
|
||||
|
||||
.lg-opt{
|
||||
margin-left:6px;padding:1px 6px;border-radius:20px;
|
||||
font-size:10px;font-weight:400;color:var(--muted);
|
||||
background:var(--surface);border:1px solid var(--line);
|
||||
}
|
||||
|
||||
.lg-submit{
|
||||
height:42px;margin-top:9px;border-radius:9px;
|
||||
font-size:13px;letter-spacing:.14em;
|
||||
box-shadow:0 9px 20px -12px var(--accent);
|
||||
}
|
||||
.lg-submit:hover:not(:disabled){box-shadow:0 11px 24px -12px var(--accent)}
|
||||
|
||||
.login-foot{
|
||||
display:flex;align-items:center;justify-content:center;gap:7px;
|
||||
margin-top:22px;padding-top:16px;
|
||||
border-top:1px solid var(--line);
|
||||
font-size:11px;color:var(--muted);
|
||||
}
|
||||
.lg-dot{width:5px;height:5px;border-radius:50%;background:var(--success);flex:none}
|
||||
|
||||
/* ---------------------------------------------------------- 主框架 */
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,25 +16,57 @@
|
||||
<!-- ===================== 登录 ===================== -->
|
||||
<div id="gate" class="gate" hidden>
|
||||
<div class="login-card">
|
||||
<h1>管理面板</h1>
|
||||
<p class="sub">优世界 · 评论 + 订阅统一管理</p>
|
||||
|
||||
<div class="login-brand">
|
||||
<span class="lb-mark">优</span>
|
||||
<div class="lb-text">
|
||||
<strong>优世界</strong>
|
||||
<span>管理面板</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-head">
|
||||
<h1>欢迎回来</h1>
|
||||
<p class="sub">登录后管理评论、订阅与文章</p>
|
||||
</div>
|
||||
|
||||
<div id="lgErr" class="alert" hidden></div>
|
||||
|
||||
<form id="loginForm">
|
||||
<label class="lbl" for="lgUser">账号</label>
|
||||
<input id="lgUser" type="text" placeholder="admin" required autocomplete="username" spellcheck="false">
|
||||
<div class="field">
|
||||
<label class="lbl" for="lgUser">账号</label>
|
||||
<div class="lg-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 12.2a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2z"/><path d="M4.8 20.2a7.2 7.2 0 0 1 14.4 0"/></svg>
|
||||
<input id="lgUser" type="text" placeholder="请输入账号" required autocomplete="username" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="lbl" for="lgPass">密码</label>
|
||||
<input id="lgPass" type="password" required autocomplete="current-password">
|
||||
<div class="field">
|
||||
<label class="lbl" for="lgPass">密码</label>
|
||||
<div class="lg-input has-peek">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4.6" y="10.4" width="14.8" height="9.6" rx="2.2"/><path d="M8.2 10.4V7.9a3.8 3.8 0 0 1 7.6 0v2.5"/><path d="M12 14.2v2.2"/></svg>
|
||||
<input id="lgPass" type="password" placeholder="请输入密码" required autocomplete="current-password">
|
||||
<button type="button" class="lg-peek" data-act="peek-pass" aria-label="显示密码" aria-pressed="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.6 12S6.1 6 12 6s9.4 6 9.4 6-3.5 6-9.4 6-9.4-6-9.4-6z"/><circle cx="12" cy="12" r="2.7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="lbl" for="lgRss">订阅口令(管理订阅/友链用,留空则跳过)</label>
|
||||
<input id="lgRss" type="password" autocomplete="off">
|
||||
<div class="field" id="lgRssField">
|
||||
<label class="lbl" for="lgRss">订阅口令 <span class="lg-opt">可选</span></label>
|
||||
<div class="lg-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8.2" cy="15.4" r="3.2"/><path d="M10.6 12.9 19.2 4.4"/><path d="M16.4 7.2l2.4 2.4"/><path d="M14 9.6l2.4 2.4"/></svg>
|
||||
<input id="lgRss" type="password" placeholder="管理订阅 / 友链用,留空则跳过" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn primary block" type="submit" id="lgBtn">登 录</button>
|
||||
<button class="btn primary block lg-submit" type="submit" id="lgBtn">登 录</button>
|
||||
</form>
|
||||
|
||||
<p class="hint" title="Cloudflare Workers 没有 TCP,发不了 SMTP">邮箱验证码登录未启用</p>
|
||||
<div class="login-foot">
|
||||
<span class="lg-dot" aria-hidden="true"></span>
|
||||
<span id="lgFoot">优世界 · 评论与订阅统一管理</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
# CNB_TOKEN CNB 访问令牌(用户名固定 cnb)。只用于 git clone 与 push。
|
||||
# EDITOR_TOKEN 与 Cloudflare Worker 里 EDITOR_TOKEN **完全相同**的共享令牌
|
||||
# (Worker 转发时会带上,后端用它鉴权;浏览器永远看不到)
|
||||
# ADMIN_PASS 后台登录页的密码(账号固定 admin,默认 3070210zz)。
|
||||
# 后台登录走 editor-api 签发的 Cookie 会话,不再用 nginx basic auth。
|
||||
#
|
||||
# 可选环境变量(都有默认值):
|
||||
# CNB_URL 默认 https://cnb.cool/zqlit/blog.git
|
||||
@@ -35,6 +37,7 @@ die() { printf '\033[1;31m!! %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
: "${CNB_TOKEN:?必须设置 CNB_TOKEN(CNB 访问令牌,用户名固定 cnb)}"
|
||||
: "${EDITOR_TOKEN:?必须设置 EDITOR_TOKEN(与 Cloudflare Worker 里的一致)}"
|
||||
: "${ADMIN_PASS:?必须设置 ADMIN_PASS(后台登录密码,账号固定 admin)}"
|
||||
|
||||
CNB_URL="${CNB_URL:-https://cnb.cool/zqlit/blog.git}"
|
||||
GH_URL="${GH_URL:-git@github.com:zqlit/blog.git}"
|
||||
@@ -93,6 +96,8 @@ umask 077
|
||||
# 用 printf 而不是 heredoc:令牌里若含 $ 或反引号,heredoc 会被 shell 展开
|
||||
{
|
||||
printf 'EDITOR_TOKEN=%s\n' "$EDITOR_TOKEN"
|
||||
printf 'ADMIN_USER=%s\n' "${ADMIN_USER:-admin}"
|
||||
printf 'ADMIN_PASS=%s\n' "$ADMIN_PASS"
|
||||
printf 'BLOG_DIR=%s\n' "$BLOG_DIR"
|
||||
printf 'TRASH_DIR=%s\n' "$TRASH_DIR"
|
||||
printf 'BIND_PORT=%s\n' "$BIND_PORT"
|
||||
@@ -142,6 +147,8 @@ services:
|
||||
GIT_AUTHOR_NAME: \${GIT_AUTHOR_NAME:-blog-editor}
|
||||
GIT_AUTHOR_EMAIL: \${GIT_AUTHOR_EMAIL:-editor@usj.cc}
|
||||
BLOG_BASE: \${BLOG_BASE:-}
|
||||
ADMIN_USER: \${ADMIN_USER:-admin}
|
||||
ADMIN_PASS: \${ADMIN_PASS:?}
|
||||
MAX_UPLOAD_MB: "20"
|
||||
GIT_PATHS: content,static
|
||||
volumes:
|
||||
|
||||
+174
-2
@@ -36,6 +36,15 @@ const cfg = {
|
||||
gitPaths: (env.GIT_PATHS || 'content,static').split(',').map((s) => s.trim()).filter(Boolean),
|
||||
// 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。
|
||||
blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''),
|
||||
|
||||
// --- 后台登录(浏览器直连本机时用,与 Cloudflare 会话是两套)---------------
|
||||
// 背景:后台静态页部署在国内机,nginx 的原生 basic auth 弹窗既丑又没法做品牌,
|
||||
// 所以改成「自建登录页 + HttpOnly Cookie 会话」。令牌通道(X-Editor-Token)
|
||||
// 保持不变,供 Cloudflare Worker 侧继续使用。
|
||||
adminUser: env.ADMIN_USER || 'admin',
|
||||
adminPass: env.ADMIN_PASS || '',
|
||||
sessionFile: env.SESSION_FILE || '',
|
||||
sessionTtlMs: Number(env.SESSION_TTL_HOURS || 168) * 3600 * 1000,
|
||||
};
|
||||
|
||||
if (!cfg.token) {
|
||||
@@ -100,6 +109,109 @@ const safeName = (s) =>
|
||||
.replace(/[^\w\u4e00-\u9fff.-]+/g, '_')
|
||||
.slice(0, 120);
|
||||
|
||||
// ------------------------------------------------------------------ 后台会话
|
||||
|
||||
/**
|
||||
* 登录态存内存 + 落一个 JSON 文件(重启不掉线)。
|
||||
* 会话 ID 是 32 字节随机数,Cookie 带 HttpOnly + SameSite=Lax,
|
||||
* 前端 JS 读不到,也就没法被 XSS 偷走。
|
||||
*/
|
||||
const SESSION_FILE =
|
||||
cfg.sessionFile || path.join(path.dirname(cfg.trashDir), '.editor-sessions.json');
|
||||
const sessions = new Map(); // sid -> { user, exp }
|
||||
|
||||
function loadSessions() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
const now = Date.now();
|
||||
for (const [sid, v] of Object.entries(raw || {})) {
|
||||
if (v && v.exp > now) sessions.set(sid, v);
|
||||
}
|
||||
} catch { /* 首次运行没有这个文件,正常 */ }
|
||||
}
|
||||
|
||||
function saveSessions() {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(SESSION_FILE), { recursive: true });
|
||||
const now = Date.now();
|
||||
const obj = {};
|
||||
for (const [sid, v] of sessions) if (v.exp > now) obj[sid] = v;
|
||||
fs.writeFileSync(SESSION_FILE, JSON.stringify(obj), { mode: 0o600 });
|
||||
} catch (e) {
|
||||
console.error('[editor-api] 会话落盘失败(不影响运行):', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
for (const part of String(req.headers.cookie || '').split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i < 1) continue;
|
||||
out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const COOKIE = 'ed_sid';
|
||||
|
||||
/** 返回会话对象或 null(顺带清掉过期项) */
|
||||
function readSession(req) {
|
||||
const sid = parseCookies(req)[COOKIE];
|
||||
if (!sid) return null;
|
||||
const s = sessions.get(sid);
|
||||
if (!s) return null;
|
||||
if (s.exp <= Date.now()) {
|
||||
sessions.delete(sid);
|
||||
saveSessions();
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setCookie(res, sid, maxAgeSec) {
|
||||
const parts = [
|
||||
COOKIE + '=' + sid,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'Secure',
|
||||
'SameSite=Lax',
|
||||
'Max-Age=' + maxAgeSec,
|
||||
];
|
||||
res.setHeader('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
// 暴力破解防护:同一 IP 连续失败到阈值就冷却一段时间
|
||||
const FAIL_MAX = 5;
|
||||
const FAIL_COOL_MS = 5 * 60 * 1000;
|
||||
const failures = new Map(); // ip -> { n, until }
|
||||
|
||||
function clientIp(req) {
|
||||
return (
|
||||
String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() ||
|
||||
(req.socket && req.socket.remoteAddress) ||
|
||||
'?'
|
||||
);
|
||||
}
|
||||
|
||||
function failState(ip) {
|
||||
const f = failures.get(ip);
|
||||
if (!f) return null;
|
||||
if (f.until && f.until > Date.now()) return f;
|
||||
if (!f.until && f.n < FAIL_MAX) return f;
|
||||
failures.delete(ip);
|
||||
return null;
|
||||
}
|
||||
|
||||
function noteFailure(ip) {
|
||||
const f = failures.get(ip) || { n: 0, until: 0 };
|
||||
f.n += 1;
|
||||
if (f.n >= FAIL_MAX) {
|
||||
f.until = Date.now() + FAIL_COOL_MS;
|
||||
f.n = 0;
|
||||
}
|
||||
failures.set(ip, f);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 路由
|
||||
|
||||
const routes = [];
|
||||
@@ -223,8 +335,60 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!safeEqual(req.headers['x-editor-token'] || '', cfg.token)) {
|
||||
json(res, 401, { error: '未授权' });
|
||||
// ---------------------------------------------------------- 后台登录
|
||||
// 这三个端点免令牌:浏览器直接访问国内机后台时用。密码存 .env,公网只能
|
||||
// 通过 nginx 的 /api/v2/admin/* 转发进来;失败限速防爆破。
|
||||
if (url.pathname === '/admin/session' && req.method === 'GET') {
|
||||
const s = readSession(req);
|
||||
if (!s) {
|
||||
json(res, 401, { ok: false, mode: 'direct', need_login: true });
|
||||
return;
|
||||
}
|
||||
json(res, 200, { ok: true, mode: 'direct', user: { name: s.user } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/login' && req.method === 'POST') {
|
||||
if (!cfg.adminPass) {
|
||||
json(res, 503, { error: '后端未配置登录密码(ADMIN_PASS),请先在 .env 里设置' });
|
||||
return;
|
||||
}
|
||||
const ip = clientIp(req);
|
||||
const f = failState(ip);
|
||||
if (f && f.until) {
|
||||
json(res, 429, { error: '尝试过于频繁,请 ' + Math.ceil((f.until - Date.now()) / 1000) + ' 秒后再试' });
|
||||
return;
|
||||
}
|
||||
const body = await readJson(req, 64 * 1024);
|
||||
if (!safeEqual(body.user || '', cfg.adminUser) || !safeEqual(body.password || '', cfg.adminPass)) {
|
||||
noteFailure(ip);
|
||||
// 不区分「账号错」和「密码错」,别帮对方缩小猜测范围
|
||||
json(res, 401, { error: '账号或密码不正确' });
|
||||
return;
|
||||
}
|
||||
failures.delete(ip);
|
||||
const sid = crypto.randomBytes(32).toString('hex');
|
||||
sessions.set(sid, { user: cfg.adminUser, exp: Date.now() + cfg.sessionTtlMs });
|
||||
saveSessions();
|
||||
setCookie(res, sid, Math.floor(cfg.sessionTtlMs / 1000));
|
||||
json(res, 200, { ok: true, user: { name: cfg.adminUser } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === '/admin/logout' && req.method === 'POST') {
|
||||
const sid = parseCookies(req)[COOKIE];
|
||||
if (sid && sessions.delete(sid)) saveSessions();
|
||||
setCookie(res, '', 0);
|
||||
json(res, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 鉴权
|
||||
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
|
||||
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
|
||||
const byToken = safeEqual(req.headers['x-editor-token'] || '', cfg.token);
|
||||
if (!byToken && !readSession(req)) {
|
||||
json(res, 401, { error: '未授权', need_login: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -248,9 +412,17 @@ const server = http.createServer(async (req, res) => {
|
||||
server.headersTimeout = 30_000;
|
||||
server.requestTimeout = 120_000;
|
||||
|
||||
loadSessions();
|
||||
|
||||
server.listen(cfg.port, cfg.host, () => {
|
||||
console.log('[editor-api] 已启动 http://' + cfg.host + ':' + cfg.port);
|
||||
console.log('[editor-api] 仓库 ' + cfg.repoRoot);
|
||||
console.log('[editor-api] 分支 ' + cfg.branch + ' 推送远端 ' + cfg.pushRemotes.join(', '));
|
||||
console.log('[editor-api] 回收目录 ' + cfg.trashDir);
|
||||
console.log(
|
||||
'[editor-api] 后台登录 ' +
|
||||
(cfg.adminPass
|
||||
? '已启用(账号 ' + cfg.adminUser + ',会话 ' + Math.round(cfg.sessionTtlMs / 3600000) + ' 小时,' + sessions.size + ' 个有效会话)'
|
||||
: '未配置 ADMIN_PASS —— 只能走 X-Editor-Token')
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user