邮件模板微信化 + 评论老友自动填充 + 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
@@ -2,6 +2,7 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.selftest-build/
|
||||
.mail-build/
|
||||
*.log
|
||||
|
||||
# 机密(本地开发用,不提交)
|
||||
|
||||
@@ -346,6 +346,12 @@ function renderTabs() {
|
||||
html += '<button class="tab' + (state.tab === t.id ? ' on' : '') + '" data-act="tab" data-id="' + t.id + '">'
|
||||
+ (ICONS[t.icon] || '') + '<span>' + t.label + '</span>' + badge + '</button>';
|
||||
}
|
||||
// 国内直连入口:Cloudflare 晚高峰丢包,写文章卡的时候一键切到国内机后台
|
||||
// (那边独立登录,点过去会由 Worker 先签一张「免登录票」换会话 Cookie)
|
||||
html += '<div class="nav-sep"></div>' +
|
||||
'<div class="nav-group">线路</div>' +
|
||||
'<button class="tab" data-act="cn-line">' + I('<path d="M3 12h4l3 -7 4 14 3 -7h4"/>') +
|
||||
'<span>国内线路(直连)</span></button>';
|
||||
$('#tabs').innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -1846,6 +1852,19 @@ document.addEventListener('click', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (act === 'cn-line') {
|
||||
// 一键跳国内机后台:先向 Worker 要一张「免登录票」(Worker 用共享令牌
|
||||
// 签名,国内机验签后直接发会话 Cookie),失败就退回普通地址手动登录。
|
||||
toast('正在打开国内线路…');
|
||||
let url = 'https://writeapi.usj.cc/admin/';
|
||||
try {
|
||||
const d = await api('/editor/handoff');
|
||||
if (d && d.url) url = d.url;
|
||||
} catch (e) { /* 拿不到票就直开,让用户手动登录 */ }
|
||||
window.open(url, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 文章编辑
|
||||
if (act === 'po-reload') { el.dataset.busy = '1'; try { await loadPosts(); } finally { delete el.dataset.busy; } return; }
|
||||
if (act === 'po-prev') { state.po.page = Math.max(1, state.po.page - 1); await loadPosts(); return; }
|
||||
|
||||
@@ -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, '需要管理员权限');
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
// 渲染两封示例邮件 HTML 到 .mail-preview/,供浏览器预览(不发送)
|
||||
// 渲染两封示例邮件 HTML 到 .mail-preview/,供浏览器预览与 SMTP 试寄(不经过 Worker)
|
||||
// 用法(在仓库根目录):
|
||||
// npx tsc tools/preview-mail.ts src/lib/mail.ts --outDir .mail-build --module commonjs --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop --noEmit false
|
||||
// node .mail-build/tools/preview-mail.js
|
||||
import * as path from 'path';
|
||||
const { replyMailHtml, adminMailHtml, replyMailSubject, adminMailSubject } = require('../src/lib/mail');
|
||||
|
||||
const now = Date.now();
|
||||
const parent = {
|
||||
const parentMsg = {
|
||||
id: 15630,
|
||||
content: '终于恢复更新了啊 确实有点无聊了,做些损人不利己的事 :prophet:',
|
||||
content: '终于恢复更新了啊,最近也在考虑要不要再次拥抱静态 🤔',
|
||||
created_at: now - 3600_000,
|
||||
page_key: '/comment.html',
|
||||
};
|
||||
const reply = {
|
||||
id: 15631,
|
||||
content: '恢复了,今天有空,把服务全部迁移到 cloudflare 上了,GitHub 仓库只做文件备份了 😄[doge]',
|
||||
content:
|
||||
'恢复了,今天有空,把服务全部迁移到 cloudflare 上了 ' +
|
||||
'<img src="https://usj.cc/emotion/douyin/1.png" atk-emoticon="1">' +
|
||||
' 欢迎回来常玩!<img src="https://usj.cc/emotion/bilibili/bq_1.png" atk-emoticon="2">',
|
||||
created_at: now,
|
||||
page_key: '/comment.html',
|
||||
};
|
||||
const newMsg = {
|
||||
id: 15632,
|
||||
content: '测试留言:这个聊天气泡样式的邮件通知看起来真不错!',
|
||||
content:
|
||||
'测试留言:这个聊天气泡样式的邮件通知看起来真不错!' +
|
||||
'<img src="https://usj.cc/emotion/weibo/wb1.png" atk-emoticon="33">' +
|
||||
'<img src="https://usj.cc/emotion/twemoji/angry.png" atk-emoticon="1">' +
|
||||
'<img src="https://pic.imgdb.cn/item/64ec38b4661c6c8e54c5acc2.jpg" alt="配图" title="配图">',
|
||||
created_at: now,
|
||||
page_key: '/comment.html',
|
||||
};
|
||||
@@ -29,7 +35,7 @@ const site = { siteName: '优世界', siteUrl: 'https://usj.cc', pageTitle: '留
|
||||
|
||||
const replyHtml = replyMailHtml({
|
||||
...site,
|
||||
parentComment: parent,
|
||||
parentComment: parentMsg,
|
||||
parentNick: '扳布',
|
||||
parentEmail: 'banbu@example.com',
|
||||
comment: reply,
|
||||
@@ -49,8 +55,14 @@ const fs = require('fs');
|
||||
fs.mkdirSync(__dirname + '/.mail-preview', { recursive: true });
|
||||
fs.writeFileSync(__dirname + '/.mail-preview/reply.html', replyHtml);
|
||||
fs.writeFileSync(__dirname + '/.mail-preview/notice.html', adminHtml);
|
||||
// 试寄用的主题也落盘,发送脚本直接读
|
||||
fs.writeFileSync(__dirname + '/.mail-preview/reply.subject.txt', replyMailSubject({
|
||||
...site, parentComment: parentMsg, parentNick: 'a', parentEmail: 'a@a.com',
|
||||
comment: reply, commenterNick: '小赵', commenterEmail: 'b@b.com', toNick: '扳布',
|
||||
}));
|
||||
fs.writeFileSync(__dirname + '/.mail-preview/notice.subject.txt', adminMailSubject({ ...site, comment: newMsg, commenterNick: 'c', commenterEmail: 'c@c.com', adminNick: 'x' }));
|
||||
console.log('SUBJECT reply :', replyMailSubject({
|
||||
...site, parentComment: parent, parentNick: 'a', parentEmail: 'a@a.com',
|
||||
...site, parentComment: parentMsg, parentNick: 'a', parentEmail: 'a@a.com',
|
||||
comment: reply, commenterNick: '小赵', commenterEmail: 'b@b.com', toNick: '扳布',
|
||||
}));
|
||||
console.log('SUBJECT notice:', adminMailSubject({ ...site, comment: newMsg, commenterNick: 'c', commenterEmail: 'c@c.com', adminNick: 'x' }));
|
||||
|
||||
@@ -184,6 +184,18 @@ function setCookie(res, sid, maxAgeSec) {
|
||||
res.setHeader('Set-Cookie', parts.join('; '));
|
||||
}
|
||||
|
||||
/** HMAC-SHA256(key=共享令牌, msg=时间戳)的 hex,供「一键跳转」验签 */
|
||||
function hmacHex(key, msg) {
|
||||
try {
|
||||
return crypto.createHmac('sha256', key).update(msg).digest('hex');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 已消费的一键跳转签名(防重放):同一签名只允许换一次会话 */
|
||||
const usedHandoff = new Set();
|
||||
|
||||
// 暴力破解防护:同一 IP 连续失败到阈值就冷却一段时间
|
||||
const FAIL_MAX = 5;
|
||||
const FAIL_COOL_MS = 5 * 60 * 1000;
|
||||
@@ -441,6 +453,33 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ 一键跳转(免登录)
|
||||
// Cloudflare 后台点「国内线路」时走这里:Worker 用共享令牌对时间戳做
|
||||
// HMAC 签名,本服务验签通过就直接发会话 Cookie 并 302 到 /admin/。
|
||||
// 三重防线:签名只有两边知道 / 时间窗 2 分钟 / 同一签名只用一次。
|
||||
if (url.pathname === '/admin/handoff' && req.method === 'GET') {
|
||||
const ts = String(url.searchParams.get('ts') || '');
|
||||
const sig = String(url.searchParams.get('t') || '');
|
||||
const bad = () => {
|
||||
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('handoff rejected');
|
||||
};
|
||||
if (!/^\d{13}$/.test(ts) || Math.abs(Date.now() - Number(ts)) > 120_000) return bad();
|
||||
const expect = hmacHex(cfg.token, ts);
|
||||
if (!expect || !safeEqual(sig, expect)) return bad();
|
||||
if (usedHandoff.has(sig)) return bad(); // 防重放:一个签名只能换一次会话
|
||||
usedHandoff.add(sig);
|
||||
if (usedHandoff.size > 500) usedHandoff.clear(); // 简单兜底,防集合无限涨
|
||||
|
||||
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));
|
||||
res.writeHead(302, { Location: '/admin/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- 鉴权
|
||||
// 两条通道:Cloudflare Worker 注入的共享令牌(浏览器看不到),
|
||||
// 或本机签发的浏览器会话 Cookie。满足其一即放行。
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* comment-autofill —— 老朋友「填邮箱自动带出昵称和网址」
|
||||
*
|
||||
* 场景:很多人隔几个月来评论一次,早忘了上次填的昵称/网址,每次都重新想。
|
||||
* 这个模块做两件事:
|
||||
* 1. 访客开始在昵称框打字时,弹一次 toast 提示「填邮箱可以自动带回来」;
|
||||
* 2. 邮箱填完(失焦或格式完整)就问后端:这个邮箱上次评论用的什么昵称/网址,
|
||||
* 拿到后回填到空着的输入框,让老朋友少打几个字。
|
||||
*
|
||||
* 隐私红线:后端 /comments/lookup **只回昵称和网址,永远不回邮箱**;
|
||||
* 邮箱只作为查询参数发给评论后端(跟提交评论本身一样),不在本地落任何存储,
|
||||
* 也不打日志。查不到就安静地什么都不做,绝不给访客「你在被查询」的感觉。
|
||||
*
|
||||
* 实现说明:用 document 级事件委托(capture),不绑定具体输入框 ——
|
||||
* Artalk 的编辑器会反复重建(PJAX 切页、点回复、登录态变化),委托一劳永逸。
|
||||
*/
|
||||
(function () {
|
||||
if (window.__atkAutofillReady) return; // PJAX 重复执行防抖
|
||||
window.__atkAutofillReady = true;
|
||||
|
||||
var HINT_KEY = 'atk_friend_hint'; // sessionStorage:提示只弹一次
|
||||
var asked = {}; // 查过的邮箱 → 结果,避免重复请求
|
||||
var timer = null;
|
||||
|
||||
function toast(msg, type, duration) {
|
||||
try {
|
||||
if (window.Toast && typeof window.Toast[type || 'info'] === 'function') {
|
||||
window.Toast[type || 'info'](msg, duration);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function isEmail(s) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(s || '').trim()); }
|
||||
|
||||
function editorOf(el) {
|
||||
var box = el && el.closest ? el.closest('.atk-editor') : null;
|
||||
return box || null;
|
||||
}
|
||||
|
||||
function fieldIn(editor, name) {
|
||||
return editor ? editor.querySelector('input[name="' + name + '"]') : null;
|
||||
}
|
||||
|
||||
/** Artalk 对受控输入有自己的状态,改完必须补一个 input 事件它才认 */
|
||||
function setVal(input, value) {
|
||||
if (!input) return;
|
||||
input.value = value;
|
||||
try { input.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {}
|
||||
}
|
||||
|
||||
function showHint(editor) {
|
||||
try {
|
||||
if (sessionStorage.getItem(HINT_KEY)) return;
|
||||
sessionStorage.setItem(HINT_KEY, '1');
|
||||
} catch (e) { return; } // 存储被禁用就别弹了,宁缺毋滥
|
||||
var email = fieldIn(editor, 'email');
|
||||
if (email && email.value.trim()) return; // 已经填了邮箱,不用提示
|
||||
toast('老朋友?填一下邮箱,昵称和网址会自动帮你带回来 ✨', 'info', 4500);
|
||||
}
|
||||
|
||||
function fillFrom(editor, data) {
|
||||
if (!data || !data.found) return;
|
||||
var nick = fieldIn(editor, 'nick');
|
||||
var link = fieldIn(editor, 'link');
|
||||
var name = String(data.name || '').trim();
|
||||
var url = String(data.link || '').trim();
|
||||
if (!name) return;
|
||||
|
||||
var filled = [];
|
||||
// 昵称:空着,或者只打了历史昵称的开头(说明 TA 正在凭印象敲同一个名字)
|
||||
// 才替 TA 补全 —— 已经敲了一个完全不同的名字就别抢,那是故意的。
|
||||
if (nick) {
|
||||
var cur = nick.value.trim();
|
||||
var lower = cur.toLowerCase();
|
||||
var lowerName = name.toLowerCase();
|
||||
if (!cur || lowerName.indexOf(lower) === 0) {
|
||||
if (cur !== name) { setVal(nick, name); filled.push('昵称'); }
|
||||
}
|
||||
}
|
||||
if (link && !link.value.trim() && url) { setVal(link, url); filled.push('网址'); }
|
||||
|
||||
if (filled.length) {
|
||||
toast('欢迎回来,' + name + '!已帮你填好' + filled.join('和') + ' 👋', 'success', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function lookup(editor, email) {
|
||||
var key = email.toLowerCase();
|
||||
if (asked[key]) { fillFrom(editor, asked[key]); return; }
|
||||
|
||||
var server = (window.artalkConfig && window.artalkConfig.server || '').replace(/\/+$/, '');
|
||||
if (!server) return;
|
||||
|
||||
// 超时兜底:评论后端在国内访问偶尔抖,别让访客干等
|
||||
var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||
var killer = null;
|
||||
if (ctrl) killer = setTimeout(function () { try { ctrl.abort(); } catch (e) {} }, 8000);
|
||||
|
||||
fetch(server + '/api/v2/comments/lookup?email=' + encodeURIComponent(email), {
|
||||
method: 'GET',
|
||||
credentials: 'omit',
|
||||
signal: ctrl ? ctrl.signal : undefined
|
||||
}).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
|
||||
asked[key] = d || { found: false };
|
||||
fillFrom(editor, asked[key]);
|
||||
}).catch(function () {
|
||||
asked[key] = { found: false }; // 失败也记一下,别反复打同一个请求
|
||||
}).finally(function () { if (killer) clearTimeout(killer); });
|
||||
}
|
||||
|
||||
// ── 事件委托(capture:Artalk 内部可能 stopPropagation)──────────────
|
||||
document.addEventListener('input', function (e) {
|
||||
var el = e.target;
|
||||
if (!el || el.tagName !== 'INPUT' || !el.name) return;
|
||||
var editor = editorOf(el);
|
||||
if (!editor) return; // 只管 Artalk 的评论框
|
||||
|
||||
if (el.name === 'nick') {
|
||||
if (el.value.trim()) showHint(editor); // 打了第一个字就开始提示
|
||||
return;
|
||||
}
|
||||
if (el.name === 'email') {
|
||||
var v = el.value.trim();
|
||||
if (isEmail(v)) {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function () { lookup(editor, v); }, 700); // 停手再查
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focusout', function (e) {
|
||||
var el = e.target;
|
||||
if (!el || el.tagName !== 'INPUT' || el.name !== 'email') return;
|
||||
var editor = editorOf(el);
|
||||
if (!editor) return;
|
||||
var v = el.value.trim();
|
||||
if (!isEmail(v)) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
lookup(editor, v); // 离开邮箱框就立刻查
|
||||
}, true);
|
||||
})();
|
||||
@@ -141,8 +141,10 @@
|
||||
{{ $human := resources.Get "js/modules/human.js" }}
|
||||
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
|
||||
{{ $reward := resources.Get "js/modules/reward.js" }}
|
||||
{{/* 老朋友自动填充:填邮箱带出上次的昵称/网址 */}}
|
||||
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
|
||||
|
||||
{{ $pageScripts := slice $friendlink $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
|
||||
<script defer src="{{ $pageScripts.RelPermalink }}"></script>
|
||||
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
|
||||
@@ -154,8 +156,9 @@
|
||||
{{ $human := resources.Get "js/modules/human.js" }}
|
||||
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
|
||||
{{ $reward := resources.Get "js/modules/reward.js" }}
|
||||
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
|
||||
|
||||
{{ $pageScripts := slice $friendlink $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
|
||||
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
|
||||
{{ end }}
|
||||
|
||||
Reference in new issue
Block a user