feat(comments): 人机验证改点按式 + 分割线可配置 + 投票契约修复

- 人机验证抽成 human.js 模块,改成 Cloudflare Turnstile 风格的「点一下验证」,
  控件移到「评论一下」发送按钮左侧(不再用图形验证码那一套)
- 留言页 / 文章页两段内联脚本(友链弹窗、人机验证)抽成模块随 page-only.js 加载。
  原先内联 <script> 写在 #pjax-container 外,PJAX 只换容器内容不重新执行它,
  从首页 PJAX 进留言页会出现「申请友链卡片点了没反应」「没有人机验证控件」
- 友链申请弹窗 #flApplyBox 隐藏滚动条(内容仍可滚,避开细滚动条 + 毛玻璃的割裂感)
- 内容分割线抽成 partial divider.html,hugo.toml [params.divider] 可切
  icon(原生 uk-divider-icon)/ image(本地图片),当前用 icon
- 投票契约修复(后端 blog-admin):Artalk 客户端把方向编在 target_name 后缀
  (comment_up / comment_down / page_up / page_down)、路径只有两段,
  而原先只注册了三段 :choice 路由 → 点赞/踩一路 404、界面点了没反应。
  补 POST /votes/:target_name/:target_id,新增 parseVoteTarget() 同时兼容两套契约
  (名字里与路径里方向矛盾时判非法,不静默取其一),错误文案改用实体名
- 无身份点赞不再静默失败:artalk.js 加 __atkVoteGuard,
  未填昵称/邮箱时拦截并提示「填写昵称和邮箱后才能点赞」,
  同时把光标送到昵称框(多个同名输入框逐个试,以 focus 真生效为判据)
This commit is contained in:
zqlit committed 2026-10-04 16:26:28 +08:00
1 parent 50a906e07c
commit e0c5ac5739
12 files changed
+513 -274

No files matched your search

+5
View File
@@ -39,7 +39,12 @@ router.put('/comments/:id', C.updateComment);
router.del('/comments/:id', C.deleteComment);
// ---------------------------------------------------------------- 投票 / 浏览量
// ★ 投票:Artalk 客户端把「方向」编进 target_name(comment_up / comment_down /
// page_up / page_down),路径只有两段 → `POST /votes/:target_name/:target_id`。
// 早期这里只注册了三段形式(:choice),与客户端对不上,导致点赞点了 404 没反应。
// 现在两段(客户端契约)与三段(旧形式)都收。
router.get('/votes/:target_name/:target_id', P.voteGet);
router.post('/votes/:target_name/:target_id', P.voteCreate);
router.post('/votes/:target_name/:target_id/:choice', P.voteCreate);
router.post('/votes/sync', P.voteSync);
router.post('/pages/pv', P.pagePV);
+46 -8
View File
@@ -205,15 +205,51 @@ export async function verifyCaptcha(ctx: Ctx): Promise<Response> {
// ==================================================================== 投票
type VoteType = 'comment' | 'page';
type VoteChoice = 'up' | 'down';
/**
* 解析投票目标,同时兼容两套契约(★ 2026-10-04 修正):
*
* 1) **Artalk 客户端实际发的**:target_name 里已经编了方向 ——
* `comment_up` / `comment_down` / `page_up` / `page_down`,路径只有两段。
* (客户端源码:`vote(`comment_${dir}`, id, userFields)`
* → `POST /votes/:target_name/:target_id`)
* 2) 本项目早期注册的三段形式:target_name = `comment`/`page` + 单独的 `:choice` 段。
*
* 之前只认第 2 种,而客户端的请求打的是第 1 种形状 → 点赞/踩一路 404,
* 界面上点了没任何反应。返回 null 表示目标名非法。
*/
function parseVoteTarget(
targetName: string,
choiceParam?: string,
): { type: VoteType; choice?: VoteChoice } | null {
const m = /^(comment|page)(?:_(up|down))?$/.exec(targetName || '');
if (!m) return null;
const type = m[1] as VoteType;
const embedded = m[2] as VoteChoice | undefined;
if (choiceParam) {
if (choiceParam !== 'up' && choiceParam !== 'down') return null;
// 名字里和路径里都写了方向却不一致 → 判非法,别静默按其中一个处理
if (embedded && embedded !== choiceParam) return null;
return { type, choice: choiceParam };
}
return { type, choice: embedded };
}
export async function voteGet(ctx: Ctx): Promise<Response> {
const targetName = ctx.params.target_name;
const targetId = parseInt(ctx.params.target_id, 10);
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
if (!type) return fail(404, 'unknown vote target name');
// 查询只关心「是评论还是页面」,方向忽略(up/down 一起返回)
const parsed = parseVoteTarget(targetName, ctx.params.choice);
if (!parsed) return fail(404, 'unknown vote target name');
const type = parsed.type;
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
const row = await voteTotals(ctx.env, type, targetId);
if (!row) return fail(404, `${targetName} not found`);
if (!row) return fail(404, `${type} not found`);
let isUp = false;
let isDown = false;
@@ -252,17 +288,19 @@ async function voteTotals(
export async function voteCreate(ctx: Ctx): Promise<Response> {
const targetName = ctx.params.target_name;
const targetId = parseInt(ctx.params.target_id, 10);
const choice = ctx.params.choice;
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
if (!type) return fail(404, 'unknown vote target name');
if (choice !== 'up' && choice !== 'down') return fail(404, 'unknown vote choice');
// 方向可能来自名字后缀(comment_up,Artalk 客户端),也可能来自三段路径的 :choice
const parsed = parseVoteTarget(targetName, ctx.params.choice);
if (!parsed) return fail(404, 'unknown vote target name');
const type = parsed.type;
const choice = parsed.choice;
if (!choice) return fail(404, 'unknown vote choice');
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
const ip = getClientIP(ctx.req);
if (!(await rateLimit(ctx.env, `vote:${ip}`, 30, 60))) return fail(429, 'Too many requests');
const totals = await voteTotals(ctx.env, type, targetId);
if (!totals) return fail(404, `${targetName} not found`);
if (!totals) return fail(404, `${type} not found`);
const body = await readBody(ctx.req);
let voter = ctx.user;