邮件模板微信化 + 评论老友自动填充 + 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:
1 parent
18a4882577
commit
bcfa229105
10 files changed
+443
-87
No files matched your search
@@ -45,9 +45,13 @@ router.post('/editor/upload', E.upload);
|
||||
router.get('/editor/git/status', E.gitStatus);
|
||||
router.post('/editor/git/publish', E.publish);
|
||||
router.post('/editor/git/sync', E.sync);
|
||||
router.get('/editor/handoff', E.handoff);
|
||||
|
||||
// ---------------------------------------------------------------- 评论
|
||||
// ★ /comments/lookup 必须注册在 /comments/:id 之前 —— 路由按注册顺序匹配,
|
||||
// 两段路径下 `:id` 会把 "lookup" 吃掉。
|
||||
router.get('/comments', C.listComments);
|
||||
router.get('/comments/lookup', C.lookupCommenter);
|
||||
router.post('/comments', C.createComment);
|
||||
router.get('/comments/:id', C.getComment);
|
||||
router.put('/comments/:id', C.updateComment);
|
||||
|
||||
+140
-77
@@ -107,115 +107,169 @@ export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 模板 —— 与 artalk.yml 里 mail_tpl / notify_tpl 指向的两个文件一致
|
||||
// 改模板只需要动下面两段字符串
|
||||
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
|
||||
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
|
||||
// 来传达「谁说了什么」,一眼就懂,不需要解释。
|
||||
//
|
||||
// 风格:聊天对话式(自己的评论靠右绿色气泡,对方靠左白色气泡),
|
||||
// 配色清新(浅薄荷底 + 白卡 + 鲜绿主色 #2fa872)。
|
||||
// 以站点的老模板(notice.html / reply.html)为底改:头像(weavatar,邮箱
|
||||
// md5,明文不出现在邮件里)、点气泡跳转评论页这些老设计都保留。
|
||||
//
|
||||
// 相比老模板的两处实质修复:
|
||||
// 1. 表情包尺寸内联到位:老模板靠 head 里 `.comment_img img{width:100px}`
|
||||
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
|
||||
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
|
||||
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
|
||||
// 内联尺寸:表情 max-height 28px(微信小表情的体感),配图限宽 100%。
|
||||
// 2. 布局全用嵌套 <table>(气泡本身就是一个表格,宽度随内容收缩、
|
||||
// 上限 360px),不依赖 flex / float / inline-block —— Outlook 与
|
||||
// 各家 webmail 对表格的支持是最稳的,图片限宽也不会算错。
|
||||
//
|
||||
// 另外管理员邮件主题由「新的评论待审」改成一句人话(见 ADMIN_SUBJECT)。
|
||||
// ============================================================================
|
||||
|
||||
/** 取首字(码点安全,emoji/生僻字不会被截断),用作头像字 */
|
||||
/** 取首字(码点安全,emoji/生僻字不会被截断) */
|
||||
const firstChar = (v: unknown): string => {
|
||||
const s = String(v ?? '').trim();
|
||||
return [...s][0] || '?';
|
||||
};
|
||||
|
||||
/** 回复提醒(原 reply.html) */
|
||||
/** 气泡里的正文块(保留老模板的 .comment_img 类名,供 head 样式兜底) */
|
||||
const BODY_OPEN = '<div class="comment_img">';
|
||||
const BODY_CLOSE = '</div>';
|
||||
|
||||
/** 老模板 head 里的那点样式:整体 reset + 表情尺寸兜底(内联样式为主,这只是保险) */
|
||||
const HEAD_STYLE = `<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
img[atk-emoticon]{max-height:28px;width:auto;vertical-align:middle}
|
||||
</style>`;
|
||||
|
||||
/** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */
|
||||
const AVATAR = (prefix: string, nick: string) =>
|
||||
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" width="40" height="40" alt="${nick}" style="width:40px;height:40px;border-radius:6px;display:block;border:0">`;
|
||||
|
||||
/**
|
||||
* 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。
|
||||
* 气泡本身是一个嵌套 <table>(宽度随内容收缩、上限 360px),比 inline-block
|
||||
* 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。
|
||||
* align="right" 时气泡贴右(微信里自己说的话)。
|
||||
*/
|
||||
const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') =>
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0"${align ? ` align="${align}"` : ''} style="max-width:360px">
|
||||
<tr><td bgcolor="${bg}" style="background-color:${bg};border-radius:8px;padding:10px 12px;font-size:14px;color:${color};line-height:1.7;word-break:break-word;text-align:left">${inner}</td></tr>
|
||||
</table>`;
|
||||
|
||||
/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */
|
||||
const REPLY_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f9f5">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
|
||||
<body style="margin:0;padding:0;background-color:#ededed">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
|
||||
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>的评论有回复</div>
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{parent_comment.datetime}}</div>
|
||||
|
||||
<div style="margin-bottom:18px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="top" align="right" style="text-align:right;padding-right:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{parent_comment.nick}}</div>
|
||||
${BUBBLE('#95ec69', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}</a>`, 'right')}
|
||||
</td>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('parent_comment', '{{parent_comment.nick}}')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
|
||||
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
|
||||
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 对话</div>
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{comment.datetime}}</div>
|
||||
|
||||
<div style="display:flex;flex-direction:row-reverse;gap:8px;margin-bottom:14px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#2fa872;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{parent_comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px;text-align:right">{{parent_comment.nick}} · {{parent_comment.datetime}}</div>
|
||||
<div style="background-color:#d7f0e2;border-radius:12px 2px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{parent_comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
|
||||
查看并回复
|
||||
</a>
|
||||
<div style="margin-bottom:8px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
|
||||
<td valign="top" style="padding-left:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
|
||||
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
|
||||
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
|
||||
如果你不想再收到这类通知,可以在评论时关闭"接收邮件通知"
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td align="center" style="padding-top:26px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">查看并回复</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
|
||||
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
|
||||
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/** 管理员通知(原 notice.html) */
|
||||
/** 管理员通知:一条新评论(白泡靠左),微信式 */
|
||||
const NOTICE_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f9f5">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
|
||||
<body style="margin:0;padding:0;background-color:#ededed">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
|
||||
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
|
||||
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6"><a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>有了新的评论</div>
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{comment.datetime}}</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
|
||||
<td valign="top" style="padding-left:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
|
||||
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
|
||||
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
|
||||
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 新留言</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td align="center" style="padding-top:26px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">前往查看</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
|
||||
<div style="max-width:82%">
|
||||
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
|
||||
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
|
||||
前往查看
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
|
||||
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复
|
||||
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
|
||||
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>博客,请勿直接回复
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/** 主题沿用 artalk.yml 里的 mail_subject */
|
||||
export const REPLY_SUBJECT = '{{site_name}} · 新的评论回复';
|
||||
export const ADMIN_SUBJECT = '{{site_name}} · 新的评论待审';
|
||||
/**
|
||||
* 邮件主题。
|
||||
* 回复:沿用老配置「新的评论回复」。
|
||||
* 管理员:原来是「新的评论待审」——像系统告警,改成一句人话。
|
||||
*/
|
||||
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
|
||||
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
|
||||
|
||||
// ------------------------------------------------------------------ 渲染
|
||||
|
||||
@@ -226,18 +280,27 @@ const esc = (v: unknown): string =>
|
||||
|
||||
/**
|
||||
* 评论正文 → 邮件里可用的 HTML。
|
||||
* 先走 Markdown(保留正文里的原生 HTML),再把表情图加上内联宽度 ——
|
||||
* 原模板靠 <style> 里的 `.comment_img img{width:100px}`,而多数邮件客户端会
|
||||
* 丢掉 head 里的样式表,所以这里补一份内联的。
|
||||
*
|
||||
* 1. 先走 Markdown(保留正文里的裸 <img>,即表情包);
|
||||
* 2. 再给**每个** <img> 补内联尺寸,因为邮件客户端普遍不加载 head 里的 <style>
|
||||
* (老模板的 `.comment_img img{width:100px}` 就是这么失效的,一失效表情图
|
||||
* 就按原始像素铺开 —— 站里有几套表情包是 400px+ 的大图)。
|
||||
*
|
||||
* 表情包(带 atk-emoticon 属性):max-height 28px、宽度按比例 —— 微信
|
||||
* 小表情的体感,混在文字里不打断阅读。
|
||||
* 普通配图(Markdown 图片或裸 <img>):限宽 100%,不撑破气泡。
|
||||
*
|
||||
* 想调表情大小只改 EMOTICON_STYLE 这一处。
|
||||
*/
|
||||
const EMOTICON_STYLE = 'max-height:28px;width:auto;vertical-align:middle;margin:0 1px';
|
||||
const IMG_STYLE = 'max-width:100%;height:auto;border-radius:6px';
|
||||
|
||||
function contentHtml(raw: string): string {
|
||||
return renderMarkdown(String(raw || '')).replace(
|
||||
/<img\s+([^>]*?)>/gi,
|
||||
(m, attrs: string) =>
|
||||
/atk-emoticon=/i.test(attrs) && !/style=/i.test(attrs)
|
||||
? `<img style="width:100px;height:auto;vertical-align:middle" ${attrs}>`
|
||||
: m,
|
||||
);
|
||||
return renderMarkdown(String(raw || '')).replace(/<img\b[^>]*>/gi, (tag) => {
|
||||
if (/\bstyle\s*=/i.test(tag)) return tag; // 作者自己写了样式就别覆盖
|
||||
const style = /\batk-emoticon\s*=/i.test(tag) ? EMOTICON_STYLE : IMG_STYLE;
|
||||
return tag.replace(/^<img/i, `<img style="${style}"`);
|
||||
});
|
||||
}
|
||||
|
||||
type Vars = Record<string, string>;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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, '需要管理员权限');
|
||||
|
||||
Reference in new issue
Block a user