登录页去掉订阅口令:订阅接口认后台会话 + editor-api 反代
- Worker 侧 requireAuth 新增两条通道:① Authorization 管理员会话(CF 版后台 登录后订阅模块免口令);② X-Editor-Token 共享令牌(服务端到服务端)。 原 ?token=/Cookie 通道保留,RSS 阅读器与友圈页不受影响 - editor-api 新增订阅反代(/api/feeds|links|link-apply):国内机后台的 订阅源/友链由本机带共享令牌去 CF 取,前端不再持有任何口令;GET 幂等重试 抗跨境抖动 - 登录页只留账号+密码(CF 版与国内机版统一);国内机后台侧栏新增订阅中心 入口;RSS_TOKEN / LS_RSS 前端残留全部清理
This commit is contained in:
1 parent
87c9de41c6
commit
a8b54f7482
4 files changed
+114
-51
No files matched your search
@@ -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) =>
|
||||
'<button class="tab' + (state.tab === id ? ' on' : '') + '" data-act="tab" data-id="' + id + '">' +
|
||||
(ICONS[id] || '') + '<span>' + label + '</span></button>';
|
||||
$('#tabs').innerHTML =
|
||||
'<button class="tab' + (state.tab === 'posts' ? ' on' : '') + '" data-act="tab" data-id="posts">'
|
||||
+ (ICONS.posts || '') + '<span>文章编辑</span></button>' +
|
||||
t('posts', '文章编辑') +
|
||||
'<div class="nav-sep"></div>' +
|
||||
'<div class="nav-group">订阅中心</div>' +
|
||||
t('feeds', '订阅源') + t('links', '友链') +
|
||||
'<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>';
|
||||
@@ -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', '需要订阅口令') +
|
||||
'<div class="empty">未配置订阅口令 —— 请退出后重新登录,并在登录页填写「订阅口令」。</div>';
|
||||
$('#view').innerHTML = head('订阅源管理', 'FEEDS', '未授权') +
|
||||
'<div class="empty">登录状态已失效 —— 请退出后重新登录后台。</div>';
|
||||
return;
|
||||
}
|
||||
$('#view').innerHTML += '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
||||
@@ -1008,8 +1008,8 @@ async function viewLinks() {
|
||||
} catch (e) { /* 申请列表加载失败不阻塞友链展示 */ }
|
||||
} catch (e) {
|
||||
if (e.status === 401 || e.status === 403) {
|
||||
$('#view').innerHTML = head('友链管理', 'LINKS', '需要订阅口令') +
|
||||
'<div class="empty">未配置订阅口令 —— 请退出后重新登录。</div>';
|
||||
$('#view').innerHTML = head('友链管理', 'LINKS', '未授权') +
|
||||
'<div class="empty">登录状态已失效 —— 请退出后重新登录后台。</div>';
|
||||
return;
|
||||
}
|
||||
$('#view').innerHTML += '<div class="empty">加载失败:' + esc(e.message) + '</div>';
|
||||
@@ -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;
|
||||
|
||||
@@ -52,14 +52,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 lg-submit" type="submit" id="lgBtn">登 录</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -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<boolean> {
|
||||
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=([^;]+)/);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user