邮件模板微信化 + 评论老友自动填充 + CF 后台国内线路一键跳

- 邮件模板重做成微信聊天式:灰底、自己评论绿色气泡靠右、对方白色气泡靠左、
  40px 圆角方头像(weavatar,邮箱 md5 不外泄)、时间居中、#07C160 主按钮;
  布局全嵌套表格(Outlook 稳),表情内联 max-height:28px(修掉「表情太大」)、
  配图限宽 100%;管理员主题从「新的评论待审」改成「您有一个新的评论」
- 老友自动填充:新增 /comments/lookup(只回昵称+网址,绝不含邮箱;IP 限速
  30 次/10 分钟)+ 主题 comment-autofill 模块(填昵称弹提示、填邮箱自动带出
  上次的昵称/网址,事件委托不怕 Artalk 重建编辑器)
- CF 后台新增「国内线路」一键跳:/editor/handoff 用共享令牌对时间戳 HMAC
  签名 → editor-api /admin/handoff 验签发会话 Cookie(2 分钟有效、防重放),
  晚高峰丢包时直达国内机后台免重新登录
This commit is contained in:
zqlit committed 2026-10-05 07:36:26 +08:00
1 parent a8b54f7482
commit 6046eb494c
10 files changed
+443 -87

No files matched your search

+38
View File
@@ -321,6 +321,44 @@ async function siteExists(env: Env, siteName: string): Promise<boolean> {
return !!r;
}
// ============================================================ GET /comments/lookup
/**
* 老用户识别:给邮箱,回「昵称 + 网址」。
*
* 用途:评论框里访客填完邮箱,前端调这里把 TA 上次用的昵称/网址带出来,
* 省得老朋友每次重新填。**响应里永远不含邮箱** —— 邮箱只进不出。
*
* 防枚举:这本质是个「邮箱 → 昵称/网址」的查询口,被人批量扫就能摸出
* 谁注册过、用什么昵称。所以按 IP 限速(10 分钟 30 次),并且邮箱必须
* 已经评论过才查得到(users 表只在评论/登录时才建行)。
*/
export async function lookupCommenter(ctx: Ctx): Promise<Response> {
const { env, req, url } = ctx;
const raw = (url.searchParams.get('email') || '').trim();
if (!isEmail(raw)) return ok({ found: false });
// 同一 IP 10 分钟最多 30 次:正常访客一页评论最多查一两次,30 次足够宽裕
const ip = getClientIP(req);
if (!(await rateLimit(env, `lookup:${ip}`, 30, 600))) {
return fail(429, 'Too many requests, please slow down');
}
// 大小写兜底:大多数邮箱本来就是小写,两个候选值一起做索引 seek(不扫表)
const cands = [...new Set([raw, raw.toLowerCase()])].filter(Boolean);
const ph = cands.map(() => '?').join(',');
const row = await env.DB.prepare(
`SELECT name, link FROM users
WHERE email IN (${ph}) AND is_admin = 0
ORDER BY updated_at DESC LIMIT 1`,
)
.bind(...cands)
.first<{ name: string; link: string }>();
return ok(row ? { found: true, name: row.name, link: row.link || '' } : { found: false });
}
// ============================================================ POST /comments
export async function createComment(ctx: Ctx): Promise<Response> {
+36
View File
@@ -161,6 +161,42 @@ export const publish = (ctx: Ctx) => relayJson(ctx, '/git/publish', 'POST');
export const sync = (ctx: Ctx) => relay(ctx, '/git/sync', { method: 'POST' });
// ---------------------------------------------------------------- 一键跳转
/**
* 「国内线路」一键跳转:给后台前端一个**免登录**直达国内机后台的 URL。
*
* 怎么做到免登录:浏览器直接开 writeapi.usj.cc/admin/ 是要重新输账号密码的
* (那边是独立的 Cookie 会话)。这里让 Worker 用共享令牌对当前时间戳做
* HMAC 签名,前端拿着签名去 editor-api 的 /admin/handoff 换会话 Cookie ——
* 两边本来就共享 EDITOR_TOKEN,等于用已登录的 CF 会话给国内机做了一次担保。
*
* 安全边界:签名 2 分钟内有效、同一签名只能换一次会话、且只有已登录管理员
* 能从 Worker 拿到签名。URL 全程 HTTPS,不含任何账号密码。
*/
async function hmacHex(secret: string, msg: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(msg));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
export async function handoff(ctx: Ctx): Promise<Response> {
const deny = await guard(ctx);
if (deny) return deny;
const base = String(ctx.env.EDITOR_API_BASE).replace(/\/+$/, '');
const ts = Date.now().toString();
const sig = await hmacHex(String(ctx.env.EDITOR_TOKEN), ts);
return json({ url: `${base}/api/v2/admin/handoff?ts=${ts}&t=${sig}` });
}
/** 健康检查:让后台能显示「编辑后端是否在线」(同样只认真实会话) */
export async function health(ctx: Ctx): Promise<Response> {
if (!(await requireAdminSession(ctx))) return fail(403, '需要管理员权限');