diff --git a/blog-admin/public/admin/admin.css b/blog-admin/public/admin/admin.css index 3f3ed9ad..ccf7210d 100644 --- a/blog-admin/public/admin/admin.css +++ b/blog-admin/public/admin/admin.css @@ -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} /* ---------------------------------------------------------- 主框架 */ diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index 8f75b7f1..25cc717d 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -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 = + '' + + '' + + '' + + ''; + 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)) + - '
' + esc(e.message) + '
' + - '
'; + '
' + esc(e.message) + '' + + '

文章还在服务器上没动过,直接重试即可;连续失败多半是本机到 Cloudflare 这一跳不稳。

' + + '
' + + '
' + + ' ' + + '' + + '
'; 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 diff --git a/blog-admin/public/admin/index.html b/blog-admin/public/admin/index.html index 7fcbad9e..2ce93a5f 100644 --- a/blog-admin/public/admin/index.html +++ b/blog-admin/public/admin/index.html @@ -16,25 +16,57 @@ diff --git a/deploy/editor-api/bootstrap.sh b/deploy/editor-api/bootstrap.sh index f8bca47e..a806652d 100644 --- a/deploy/editor-api/bootstrap.sh +++ b/deploy/editor-api/bootstrap.sh @@ -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: diff --git a/editor-api/server.mjs b/editor-api/server.mjs index 8ef1dc1e..80266eea 100644 --- a/editor-api/server.mjs +++ b/editor-api/server.mjs @@ -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') + ); });