diff --git a/blog-admin/public/admin/admin.js b/blog-admin/public/admin/admin.js index 25cc717d..73f7e38b 100644 --- a/blog-admin/public/admin/admin.js +++ b/blog-admin/public/admin/admin.js @@ -13,7 +13,6 @@ const API = location.origin + '/api/v2'; const RSS_API = location.origin; const LS_T = 'atk.token'; const LS_U = 'atk.user'; -const LS_RSS = 'atk.rss.token'; // 订阅口令 const $ = (s, r = document) => r.querySelector(s); const $$ = (s, r = document) => Array.prototype.slice.call(r.querySelectorAll(s)); @@ -160,18 +159,16 @@ async function api(path, opt) { return data; } -// 订阅后端请求:URL 带 ?token=<订阅口令> -let RSS_TOKEN = localStorage.getItem(LS_RSS) || ''; - +// 订阅后端请求。凭据不再单独让用户输 —— 见下方注释。 async function rssApi(path, opt) { opt = opt || {}; const u = new URL(RSS_API + path); - // 调用方显式带了 token 就用它,否则补本地记住的口令。 - // (以前无条件 set 会覆盖调用方传的值,导致「刚输入的口令被旧口令盖掉」) - if (!u.searchParams.get('token') && RSS_TOKEN) u.searchParams.set('token', RSS_TOKEN); const headers = {}; if (opt.body !== undefined) headers['Content-Type'] = 'application/json'; + // Cloudflare 版:带后台会话,订阅接口的 requireAuth 直接认(不再要「订阅口令」) + // 国内机版:TOKEN 为空,请求由本机 editor-api 反代时注入共享令牌 + if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN; let res; try { @@ -191,7 +188,7 @@ async function rssApi(path, opt) { if (!res.ok) { if (res.status === 401 || res.status === 403) { - const e = new Error((data && (data.error || data.msg)) || '订阅口令未授权'); + const e = new Error((data && (data.error || data.msg)) || '订阅模块未授权'); e.status = res.status; throw e; } @@ -239,9 +236,6 @@ 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 @@ -317,12 +311,18 @@ async function directLogin(user, password) { // ============================================================ 骨架 function renderTabs() { - // 直连模式只放「文章编辑」——其它模块(评论/设置/订阅)必须走 Cloudflare 的 D1, - // 在国内访问那一跳很慢,单独留一个入口按需登录。 + // 国内机模式:文章编辑走本机直连(不出境);订阅源/友链由本机后端反代到 + // Cloudflare 的订阅服务(带了共享令牌,前端不用口令)。评论/设置这些还 + // 必须走 Cloudflare 会话(D1 数据在国内访问那一跳很慢),单独留个入口。 if (state.direct) { + const t = (id, label) => + ''; $('#tabs').innerHTML = - '' + + t('posts', '文章编辑') + + '' + + '' + + t('feeds', '订阅源') + t('links', '友链') + '' + '' + ''; @@ -931,8 +931,8 @@ async function viewFeeds() { data = await rssApi('/api/feeds'); } catch (e) { if (e.status === 401 || e.status === 403) { - $('#view').innerHTML = head('订阅源管理', 'FEEDS', '需要订阅口令') + - '
未配置订阅口令 —— 请退出后重新登录,并在登录页填写「订阅口令」。
'; + $('#view').innerHTML = head('订阅源管理', 'FEEDS', '未授权') + + '
登录状态已失效 —— 请退出后重新登录后台。
'; return; } $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; @@ -1008,8 +1008,8 @@ async function viewLinks() { } catch (e) { /* 申请列表加载失败不阻塞友链展示 */ } } catch (e) { if (e.status === 401 || e.status === 403) { - $('#view').innerHTML = head('友链管理', 'LINKS', '需要订阅口令') + - '
未配置订阅口令 —— 请退出后重新登录。
'; + $('#view').innerHTML = head('友链管理', 'LINKS', '未授权') + + '
登录状态已失效 —— 请退出后重新登录后台。
'; return; } $('#view').innerHTML += '
加载失败:' + esc(e.message) + '
'; @@ -1986,28 +1986,6 @@ $('#loginForm').addEventListener('submit', async (e) => { state.me = state.me || {}; if (state.me.is_admin === false) throw new Error('该账号不是管理员'); - // 订阅口令是**可选**的:填了就验证,错了只影响订阅模块,绝不阻断登录 - const rssPwd = $('#lgRss') ? $('#lgRss').value.trim() : ''; - if (rssPwd) { - // 直接验证刚输入的口令(不走 rssApi,避免被本地记住的旧口令覆盖) - let rssOk = false; - try { - const r = await fetch(RSS_API + '/api/auth?token=' + encodeURIComponent(rssPwd)); - const d = await r.json().catch(() => null); - rssOk = !!(d && d.ok === true); - } catch (e) { rssOk = false; } - if (rssOk) { - RSS_TOKEN = rssPwd; - localStorage.setItem(LS_RSS, rssPwd); - } else { - RSS_TOKEN = ''; - localStorage.removeItem(LS_RSS); - setTimeout(() => toast('订阅口令不正确,已跳过订阅模块(不影响其它功能)', true), 400); - } - } else { - RSS_TOKEN = localStorage.getItem(LS_RSS) || ''; - } - await bootstrap(); } catch (ex) { err.textContent = '✕ ' + ex.message; diff --git a/blog-admin/public/admin/index.html b/blog-admin/public/admin/index.html index 2ce93a5f..71f09cab 100644 --- a/blog-admin/public/admin/index.html +++ b/blog-admin/public/admin/index.html @@ -52,14 +52,6 @@ -
- -
- - -
-
- diff --git a/blog-admin/src/lib/rss/util.ts b/blog-admin/src/lib/rss/util.ts index d7bf61a3..f33f9cbe 100644 --- a/blog-admin/src/lib/rss/util.ts +++ b/blog-admin/src/lib/rss/util.ts @@ -2,12 +2,14 @@ // 把原 EdgeOne 每个 api/*.js 里重复的 requireAuth / respond / corsOptions 收拢到一处 import type { Env } from '../../types'; +import { getAdminUsers } from '../db'; +import { userFromToken } from '../session'; export const CORS_HEADERS = { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', } as const; export function respond(data: unknown, status = 200): Response { @@ -18,8 +20,34 @@ export function corsOptions(): Response { return new Response(null, { status: 204, headers: CORS_HEADERS }); } -/** 校验站点口令:URL ?token= / Cookie site_token=。KV 无口令时放行(与原实现一致)。 */ +/** + * 订阅模块的鉴权。三条通道,满足其一即可: + * + * ① 后台管理员会话(Authorization: Bearer …) + * —— 登录后台之后,订阅/友链这些模块不该再让人手填一遍口令。 + * 后台里那个「订阅口令」输入框已经被这次改造干掉了。 + * + * ② 服务端到服务端(X-Editor-Token == 环境里的共享令牌) + * —— 国内机后台(writeapi.usj.cc)的订阅请求由本机 editor-api 反代过来, + * 它带着自己的共享令牌。这样国内机不必再存第三份口令。 + * + * ③ 站点口令(URL ?token= 或 Cookie site_token=) + * —— 原有通道,留给 RSS 阅读器、友圈页这类外部调用方,行为不变。 + */ export async function requireAuth(request: Request, env: Env): Promise { + const bearer = request.headers.get('Authorization'); + if (bearer) { + const user = await userFromToken(env, bearer); + if (user) { + if (user.is_admin) return true; + const admins = await getAdminUsers(env); + if (admins.some((a) => a.email && a.email.toLowerCase() === user.email.toLowerCase())) return true; + } + } + + const shared = request.headers.get('X-Editor-Token') || ''; + if (shared && env.EDITOR_TOKEN && shared === env.EDITOR_TOKEN) return true; + const url = new URL(request.url); const cookie = request.headers.get('Cookie') || ''; const cookieMatch = cookie.match(/site_token=([^;]+)/); diff --git a/editor-api/server.mjs b/editor-api/server.mjs index 80266eea..719166a2 100644 --- a/editor-api/server.mjs +++ b/editor-api/server.mjs @@ -37,6 +37,10 @@ const cfg = { // 博客对外地址(如 https://usj.cc)。配了才返回绝对链接,列表「预览」按钮才能直接开新窗口。 blogBase: (env.BLOG_BASE || '').replace(/\/+$/, ''), + // 订阅服务所在(Cloudflare 上的 rss-api)。国内机后台的订阅/友链请求由本服务 + // 反代过去,并带上共享令牌 —— 这样后台前端不必再持有「订阅口令」。 + rssBase: (env.RSS_API_BASE || 'https://api.200181.xyz').replace(/\/+$/, ''), + // --- 后台登录(浏览器直连本机时用,与 Cloudflare 会话是两套)--------------- // 背景:后台静态页部署在国内机,nginx 的原生 basic auth 弹窗既丑又没法做品牌, // 所以改成「自建登录页 + HttpOnly Cookie 会话」。令牌通道(X-Editor-Token) @@ -212,6 +216,60 @@ function noteFailure(ip) { failures.set(ip, f); } +// ------------------------------------------------------------------ 订阅反代 + +/** 需要转给订阅服务的路径前缀(后台的订阅源 / 友链模块用) */ +const RSS_PATH_PREFIXES = (env.RSS_PATHS || '/api/feeds,/api/links,/api/link-apply') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +/** + * 把订阅/友链请求原样转发到订阅服务(Cloudflare 上的 rss-api),带上共享令牌。 + * 用途:国内机后台的订阅源 / 友链模块 —— 请求由本机出去,前端不需要知道 + * 订阅口令,也不必为了这两个模块去登录 Cloudflare 后台。 + * + * 鉴权在调用处完成(会话或令牌),这里只管转发。 + */ +async function rssProxy(req, res, url) { + const target = cfg.rssBase + url.pathname + url.search; + const method = (req.method || 'GET').toUpperCase(); + + const headers = { 'X-Editor-Token': cfg.token, Accept: 'application/json' }; + const ct = req.headers['content-type']; + if (ct) headers['Content-Type'] = ct; + + let body; + if (method !== 'GET' && method !== 'HEAD') { + const buf = await readBody(req, 4 * 1024 * 1024); + if (buf.length) body = buf; + } + + let r; + // 跨境那一跳会偶发握手失败(实测直连 CF 有时 0.3s 就断)。GET 是幂等的, + // 重试一次基本就过去了 —— 不能让用户看到「订阅源加载失败」。 + const tries = method === 'GET' || method === 'HEAD' ? 2 : 1; + for (let i = 0; i < tries; i++) { + if (i) await new Promise((s) => setTimeout(s, 400)); + try { + r = await fetch(target, { method, headers, body }); + break; + } catch (e) { + if (i === tries - 1) { + throw Object.assign(new Error('订阅服务连不上:' + e.message), { status: 502 }); + } + } + } + + const buf = Buffer.from(await r.arrayBuffer()); + res.writeHead(r.status, { + 'Content-Type': r.headers.get('content-type') || 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Length': buf.length, + }); + res.end(buf); +} + // ------------------------------------------------------------------ 路由 const routes = []; @@ -392,6 +450,13 @@ const server = http.createServer(async (req, res) => { return; } + // 订阅 / 友链:反代到 Cloudflare 上的订阅服务,带上共享令牌。 + // 这样国内机后台也能管订阅,且前端不需要持有订阅口令。 + if (RSS_PATH_PREFIXES.some((p) => url.pathname === p || url.pathname.startsWith(p + '/'))) { + await rssProxy(req, res, url); + return; + } + const hit = match(req.method, url.pathname); if (!hit) { json(res, 404, { error: 'Not Found: ' + req.method + ' ' + url.pathname });