diff --git a/cwd-api/src/api/public/postComment.spec.ts b/cwd-api/src/api/public/postComment.spec.ts new file mode 100644 index 00000000..7de9a801 --- /dev/null +++ b/cwd-api/src/api/public/postComment.spec.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../utils/email', () => ({ + sendCommentNotification: vi.fn(), + sendCommentReplyNotification: vi.fn(), + isValidEmail: () => true, + getAdminNotifyEmail: vi.fn(), + loadEmailNotificationSettings: vi.fn(), +})); + +vi.mock('../../utils/telegram', () => ({ + loadTelegramSettings: vi.fn(), + sendTelegramMessage: vi.fn(), +})); + +import { postComment } from './postComment'; + +describe('postComment Turnstile ordering', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('rejects a recent IP before calling Siteverify', async () => { + const fetcher = vi.fn(); + vi.stubGlobal('fetch', fetcher); + + const first = vi.fn().mockResolvedValue({ created: Date.now() }); + const bind = vi.fn().mockReturnValue({ first }); + const prepare = vi.fn().mockReturnValue({ bind }); + const json = vi.fn((body, status) => ({ body, status })); + const context = { + req: { + json: vi.fn().mockResolvedValue({ + post_slug: '/post', + content: 'hello', + name: 'Reader', + email: 'reader@example.com', + turnstileToken: 'token', + }), + header: (name: string) => (name === 'cf-connecting-ip' ? '203.0.113.10' : undefined), + }, + env: { + TURNSTILE_SECRET_KEY: 'secret', + CWD_DB: { prepare }, + }, + json, + } as any; + + await postComment(context); + + expect(json).toHaveBeenCalledWith( + { message: '评论频繁,等10s后再试', turnstileConsumed: false }, + 429 + ); + expect(fetcher).not.toHaveBeenCalled(); + expect(prepare).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cwd-api/src/api/public/postComment.ts b/cwd-api/src/api/public/postComment.ts index 30dc980b..68f1cbab 100644 --- a/cwd-api/src/api/public/postComment.ts +++ b/cwd-api/src/api/public/postComment.ts @@ -13,6 +13,7 @@ import { } from '../../utils/email'; import { loadTelegramSettings, sendTelegramMessage } from '../../utils/telegram'; import { decodePostSlug } from '../../utils/decodePostSlug'; +import { verifyTurnstileToken } from '../../utils/turnstile'; export function checkContent(content: string): string { return content.replace(//g, ""); @@ -23,7 +24,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => { if (!data || typeof data !== 'object') { return c.json({ message: '无效的请求体' }, 400); } - const { post_slug: rawPostSlug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken } = data; + const { post_slug: rawPostSlug, content: rawContent, name: rawName, email, url, post_title, post_url, adminToken, turnstileToken } = data; const post_slug = decodePostSlug(rawPostSlug || ''); const site_id = data.site_id ? String(data.site_id).trim() : ""; const parentId = (data as any).parent_id ?? (data as any).parentId ?? null; @@ -46,6 +47,28 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => { const ip = c.req.header('cf-connecting-ip') || "127.0.0.1"; + // Reject known cooldown violations before consuming a single-use Turnstile token. + const lastComment = await c.env.CWD_DB.prepare( + 'SELECT created FROM Comment WHERE ip_address = ? ORDER BY created DESC LIMIT 1' + ).bind(ip).first<{ created: number }>(); + + if (lastComment && Date.now() - lastComment.created < 10 * 1000) { + return c.json({ message: "评论频繁,等10s后再试", turnstileConsumed: false }, 429); + } + + const turnstileResult = await verifyTurnstileToken(c.env, turnstileToken, ip); + if (!turnstileResult.success) { + console.warn('PostComment:turnstileRejected', { + reason: turnstileResult.reason, + errorCodes: turnstileResult.errorCodes, + ip, + }); + if (turnstileResult.reason === 'service-unavailable') { + return c.json({ message: '人机验证服务暂时不可用,请稍后再试' }, 503); + } + return c.json({ message: '请完成人机验证后再提交评论' }, 403); + } + const adminEmail = await c.env.CWD_DB.prepare('SELECT value FROM Settings WHERE key = ?') .bind('comment_admin_email') .first('value'); @@ -115,20 +138,7 @@ export const postComment = async (c: Context<{ Bindings: Bindings }>) => { isAdminComment = true; } } - // 2. 检查评论频率控制 (对应 canPostComment) - // 这里建议使用 D1 查最近一条评论的时间,或者直接放行(如果使用了 Cloudflare WAF) - const lastComment = await c.env.CWD_DB.prepare( - 'SELECT created FROM Comment WHERE ip_address = ? ORDER BY created DESC LIMIT 1' - ).bind(ip).first<{ created: number }>(); - - if (lastComment) { - const lastTime = lastComment.created; - if (Date.now() - lastTime < 10 * 1000) { - return c.json({ message: "评论频繁,等10s后再试" }, 429); - } - } - - // 3. 准备数据 + // 2. 准备数据 const cleanedContent = checkContent(rawContent); const contentText = cleanedContent; const name = checkContent(rawName); diff --git a/cwd-api/src/bindings.ts b/cwd-api/src/bindings.ts index 20068a0e..2edda5ad 100644 --- a/cwd-api/src/bindings.ts +++ b/cwd-api/src/bindings.ts @@ -6,4 +6,7 @@ export type Bindings = { MAIL_GATEWAY_TOKEN?: string ADMIN_NAME: string ADMIN_PASSWORD: string + TURNSTILE_SITE_KEY?: string + TURNSTILE_SECRET_KEY?: string + TURNSTILE_ALLOWED_HOSTNAMES?: string } diff --git a/cwd-api/src/index.ts b/cwd-api/src/index.ts index 8f765a4d..ff3abdef 100644 --- a/cwd-api/src/index.ts +++ b/cwd-api/src/index.ts @@ -280,8 +280,9 @@ app.get('/api/config/comments', async (c) => { const settings = await loadCommentSettings(c.env); const featureSettings = await loadFeatureSettings(c.env); const { adminKey, adminKeySet, blockedIps, blockedEmails, ...publicSettings } = settings as any; + const turnstileSiteKey = c.env.TURNSTILE_SECRET_KEY?.trim() ? c.env.TURNSTILE_SITE_KEY?.trim() || '' : ''; - return c.json({ ...publicSettings, ...featureSettings }); + return c.json({ ...publicSettings, ...featureSettings, turnstileSiteKey }); } catch (e: any) { return c.json({ message: e.message || '加载评论配置失败' }, 500); } diff --git a/cwd-api/src/utils/turnstile.spec.ts b/cwd-api/src/utils/turnstile.spec.ts new file mode 100644 index 00000000..bd47e420 --- /dev/null +++ b/cwd-api/src/utils/turnstile.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { verifyTurnstileToken } from './turnstile'; + +describe('verifyTurnstileToken', () => { + it('allows comments when Turnstile is not configured', async () => { + const fetcher = vi.fn(); + + await expect(verifyTurnstileToken({}, undefined, undefined, fetcher as typeof fetch)).resolves.toEqual({ + configured: false, + success: true, + }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('rejects a missing token without calling Siteverify', async () => { + const fetcher = vi.fn(); + + await expect( + verifyTurnstileToken({ TURNSTILE_SECRET_KEY: 'secret' }, '', '203.0.113.10', fetcher as typeof fetch) + ).resolves.toMatchObject({ configured: true, success: false, reason: 'missing-token' }); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('accepts a valid token for the expected action and hostname', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + hostname: 'example.com', + action: 'comment', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + + await expect( + verifyTurnstileToken( + { TURNSTILE_SECRET_KEY: 'secret', TURNSTILE_ALLOWED_HOSTNAMES: 'example.com, comments.example.com' }, + 'token', + '203.0.113.10', + fetcher as typeof fetch + ) + ).resolves.toEqual({ configured: true, success: true }); + + const request = fetcher.mock.calls[0]; + expect(request[0]).toBe('https://challenges.cloudflare.com/turnstile/v0/siteverify'); + expect(String(request[1]?.body)).toContain('remoteip=203.0.113.10'); + }); + + it('returns Siteverify error codes for an invalid token', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: false, 'error-codes': ['invalid-input-response'] }), { status: 200 }) + ); + + await expect( + verifyTurnstileToken({ TURNSTILE_SECRET_KEY: 'secret' }, 'invalid-token', undefined, fetcher as typeof fetch) + ).resolves.toEqual({ + configured: true, + success: false, + reason: 'invalid-token', + errorCodes: ['invalid-input-response'], + }); + }); + + it('fails closed when Siteverify is unavailable', async () => { + const fetcher = vi.fn().mockRejectedValue(new Error('network error')); + + await expect( + verifyTurnstileToken({ TURNSTILE_SECRET_KEY: 'secret' }, 'token', undefined, fetcher as typeof fetch) + ).resolves.toMatchObject({ success: false, reason: 'service-unavailable' }); + }); + + it('rejects tokens issued for another action or hostname', async () => { + const actionFetcher = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ success: true, hostname: 'example.com', action: 'login' }), { status: 200 })); + const hostnameFetcher = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ success: true, hostname: 'evil.example', action: 'comment' }), { status: 200 })); + + await expect( + verifyTurnstileToken({ TURNSTILE_SECRET_KEY: 'secret' }, 'token', undefined, actionFetcher as typeof fetch) + ).resolves.toMatchObject({ success: false, reason: 'action-mismatch' }); + await expect( + verifyTurnstileToken( + { TURNSTILE_SECRET_KEY: 'secret', TURNSTILE_ALLOWED_HOSTNAMES: 'example.com' }, + 'token', + undefined, + hostnameFetcher as typeof fetch + ) + ).resolves.toMatchObject({ success: false, reason: 'hostname-mismatch' }); + }); +}); diff --git a/cwd-api/src/utils/turnstile.ts b/cwd-api/src/utils/turnstile.ts new file mode 100644 index 00000000..e7f2fcb1 --- /dev/null +++ b/cwd-api/src/utils/turnstile.ts @@ -0,0 +1,100 @@ +import { Bindings } from '../bindings'; + +const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; +const EXPECTED_ACTION = 'comment'; + +type TurnstileSiteverifyResponse = { + success?: boolean; + hostname?: string; + action?: string; + 'error-codes'?: string[]; +}; + +export type TurnstileVerificationResult = { + configured: boolean; + success: boolean; + reason?: 'missing-token' | 'invalid-token' | 'hostname-mismatch' | 'action-mismatch' | 'service-unavailable'; + errorCodes?: string[]; +}; + +function parseAllowedHostnames(raw: string | undefined): string[] { + return (raw || '') + .split(',') + .map((hostname) => hostname.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Verifies a single-use Turnstile token before accepting a comment. + */ +export async function verifyTurnstileToken( + env: Pick, + token: unknown, + remoteIp?: string, + fetcher: typeof fetch = fetch +): Promise { + const secret = env.TURNSTILE_SECRET_KEY?.trim(); + if (!secret) { + return { configured: false, success: true }; + } + + if (typeof token !== 'string' || !token.trim()) { + return { configured: true, success: false, reason: 'missing-token' }; + } + if (token.length > 2048) { + return { configured: true, success: false, reason: 'invalid-token' }; + } + + const body = new URLSearchParams({ + secret, + response: token.trim(), + }); + if (remoteIp) { + body.set('remoteip', remoteIp); + } + + let response: Response; + try { + response = await fetcher(SITEVERIFY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + } catch { + return { configured: true, success: false, reason: 'service-unavailable' }; + } + + if (!response.ok) { + return { configured: true, success: false, reason: 'service-unavailable' }; + } + + let result: TurnstileSiteverifyResponse; + try { + result = (await response.json()) as TurnstileSiteverifyResponse; + } catch { + return { configured: true, success: false, reason: 'service-unavailable' }; + } + + if (!result.success) { + return { + configured: true, + success: false, + reason: 'invalid-token', + errorCodes: Array.isArray(result['error-codes']) ? result['error-codes'] : [], + }; + } + + if (result.action !== EXPECTED_ACTION) { + return { configured: true, success: false, reason: 'action-mismatch' }; + } + + const allowedHostnames = parseAllowedHostnames(env.TURNSTILE_ALLOWED_HOSTNAMES); + if (allowedHostnames.length > 0) { + const hostname = result.hostname?.trim().toLowerCase() || ''; + if (!allowedHostnames.includes(hostname)) { + return { configured: true, success: false, reason: 'hostname-mismatch' }; + } + } + + return { configured: true, success: true }; +} diff --git a/docs/api/public/comments.md b/docs/api/public/comments.md index 7e32935c..f031d82d 100644 --- a/docs/api/public/comments.md +++ b/docs/api/public/comments.md @@ -156,7 +156,8 @@ POST /api/comments "url": "https://zhangsan.me", "content": "很棒的文章!", "parent_id": 1, - "adminToken": "your-admin-key" + "adminToken": "your-admin-key", + "turnstileToken": "turnstile-response-token" } ``` @@ -173,6 +174,7 @@ POST /api/comments | `content` | string | 是 | 评论内容,内部会过滤 `` 片段 | | `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 | | `adminToken` | string | 否 | 管理员评论密钥,博主发布评论时需要先通过 `/api/verify-admin` 验证密钥后将密钥传入此字段,评论将直接通过且不受审核设置影响 | +| `turnstileToken` | string | 启用 Turnstile 时是 | Cloudflare Turnstile 返回的一次性验证 token | **请求头说明:** @@ -204,6 +206,29 @@ POST /api/comments **错误响应** +- 同一 IP 在 10 秒内重复评论: + + - 状态码:`429` + + ```json + { + "message": "评论频繁,等10s后再试", + "turnstileConsumed": false + } + ``` + + `turnstileConsumed` 为 `false` 表示请求在调用 Siteverify 前已被拒绝,前端可以保留尚未消费的验证码 token。 + +- Turnstile 验证失败或缺少 token: + + - 状态码:`403` + + ```json + { + "message": "请完成人机验证后再提交评论" + } + ``` + - 请求体缺失或字段类型错误: - 状态码:`400` diff --git a/docs/api/public/config.md b/docs/api/public/config.md index fc2b83bd..86903c94 100644 --- a/docs/api/public/config.md +++ b/docs/api/public/config.md @@ -35,6 +35,7 @@ GET /api/config/comments "enableCommentLike": true, "enableArticleLike": true, "enableImageLightbox": true, + "turnstileSiteKey": "0x4AAAAAAA...", "commentPlaceholder": "发表你的看法...", "adminLanguage": "zh-CN", "widgetLanguage": "auto" @@ -54,6 +55,7 @@ GET /api/config/comments | `enableCommentLike` | boolean | 是否启用评论点赞功能(默认 true) | | `enableArticleLike` | boolean | 是否启用文章点赞功能(默认 true) | | `enableImageLightbox`| boolean | 是否启用评论图片灯箱预览功能(默认 false) | +| `turnstileSiteKey` | string | Turnstile 站点密钥;未启用服务端验证时为空字符串 | | `commentPlaceholder` | string \| null | 评论输入框的占位符文本,留空则使用默认值 | | `adminLanguage` | string \| null | 管理后台界面语言代码,如 `zh-CN`、`en-US`,仅供后台使用 | | `widgetLanguage` | string \| null | 评论前端组件默认语言代码,支持 `auto` 自动根据浏览器语言选择 | diff --git a/docs/function/security-settings.md b/docs/function/security-settings.md index a174aa56..6cbee028 100644 --- a/docs/function/security-settings.md +++ b/docs/function/security-settings.md @@ -1,5 +1,9 @@ # 安全设置 +### Cloudflare Turnstile + +配置 `TURNSTILE_SITE_KEY` 和 `TURNSTILE_SECRET_KEY` 后,新评论和回复都必须先通过 Turnstile 人机验证。验证码 token 会由 Worker 调用 Siteverify 接口校验,同时检查固定动作 `comment`;可通过 `TURNSTILE_ALLOWED_HOSTNAMES` 进一步限制 token 的来源域名。只配置站点密钥不会启用验证。 + ### 管理员评论密钥 设置管理员评论密钥后,博主在前台使用管理员邮箱发表评论时,需要先输入正确的密钥进行身份验证。验证通过的评论将直接视为已审核,通过「先审核再显示」的限制;未通过验证时,无法使用管理员邮箱发表评论,从而降低管理员邮箱被他人冒用或滥用的风险。 diff --git a/docs/guide/backend-config.md b/docs/guide/backend-config.md index e5e91460..e8aed10e 100644 --- a/docs/guide/backend-config.md +++ b/docs/guide/backend-config.md @@ -114,6 +114,9 @@ npm install | ------------------ | ----------- | --------------------------------------------------------------------- | | `ADMIN_NAME` | string | 管理员登录名称 | | `ADMIN_PASSWORD` | string | 管理员登录密码 | +| `TURNSTILE_SITE_KEY` | string | Cloudflare Turnstile 站点密钥,配置后由公开配置接口下发 | +| `TURNSTILE_SECRET_KEY` | string | Cloudflare Turnstile 私密密钥;配置后提交评论必须通过验证码 | +| `TURNSTILE_ALLOWED_HOSTNAMES` | string | 可选,允许的验证码域名,多个域名以逗号分隔 | 在 Cloudflare 控制台中配置方式: @@ -122,6 +125,14 @@ npm install - 在 `D1 Databases` 中绑定 `CWD_DB`(默认已配置好) - 在 `KV Namespaces` 中绑定 `CWD_AUTH_KV`(默认已配置好) +Turnstile 私密密钥建议通过 Wrangler Secret 保存: + +```bash +npx wrangler secret put TURNSTILE_SECRET_KEY +``` + +`TURNSTILE_SITE_KEY` 和可选的 `TURNSTILE_ALLOWED_HOSTNAMES` 可以作为普通 Worker 变量配置。只有同时配置私密密钥和站点密钥时,前端才会显示验证码。服务端在检测到私密密钥后会强制校验 Turnstile token、`action=comment` 和可选的允许域名,因此不要只配置私密密钥,否则前端无法取得站点密钥,评论请求会被拒绝。 + ## 参考模板 @@ -159,7 +170,9 @@ npm install ], "vars": { "ADMIN_NAME": "admin@example.com", - "ADMIN_PASSWORD": "123456" + "ADMIN_PASSWORD": "123456", + "TURNSTILE_SITE_KEY": "0x4AAAAAAA...", + "TURNSTILE_ALLOWED_HOSTNAMES": "example.com,www.example.com" } } ``` \ No newline at end of file diff --git a/docs/guide/frontend-config.md b/docs/guide/frontend-config.md index ccdb5a72..2f38ebeb 100644 --- a/docs/guide/frontend-config.md +++ b/docs/guide/frontend-config.md @@ -64,6 +64,9 @@ https://cwd.js.org/cwd.js | `theme` | `'light' \| 'dark'` | 否 | `'light'` | 主题模式 | | `pageSize` | `number` | 否 | `20` | 每页显示评论数 | | `customCssUrl` | `string` | 否 | - | 自定义样式表 URL,追加到 Shadow DOM 底部 | +| `turnstileSiteKey` | `string` | 否 | 后端配置 | Cloudflare Turnstile 站点密钥,通常无需手动传入 | + +配置后端 `TURNSTILE_SITE_KEY` 和 `TURNSTILE_SECRET_KEY` 后,组件会从 `/api/config/comments` 自动读取站点密钥,并在新评论和回复提交前要求完成人机验证。 ### 多语言配置说明 diff --git a/docs/widget/src/components/CommentForm.js b/docs/widget/src/components/CommentForm.js index 091e3937..75649d04 100644 --- a/docs/widget/src/components/CommentForm.js +++ b/docs/widget/src/components/CommentForm.js @@ -5,6 +5,7 @@ import { Component } from './Component.js'; import { AdminAuthModal } from './AdminAuthModal.js'; import { EmotionPicker } from './EmotionPicker.js'; +import { TurnstileWidget } from './TurnstileWidget.js'; import { auth } from '../utils/auth.js'; import { insertTextAtCursor } from '../utils/emotions.js'; import { renderMarkdown } from '../utils/markdown.js'; @@ -39,13 +40,22 @@ export class CommentForm extends Component { }; this.modal = null; this.emotionPicker = null; + this.turnstileWidget = null; + this.turnstileToken = ''; } render() { + this.turnstileWidget?.destroy(); + this.turnstileWidget = null; + this.turnstileToken = ''; const { formErrors, submitting } = this.props; const { localForm } = this.state; - const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim(); + const canSubmit = + localForm.name.trim() && + localForm.email.trim() && + localForm.content.trim() && + (!this.props.turnstileSiteKey || this.turnstileToken); const isAdmin = this.props.adminEmail && localForm.email.trim() === this.props.adminEmail; const isVerified = isAdmin && auth.hasToken(); const placeholderText = this.props.placeholder || ''; @@ -125,6 +135,10 @@ export class CommentForm extends Component { ], }), + ...(this.props.turnstileSiteKey + ? [this.createElement('div', { className: 'cwd-turnstile-container' })] + : []), + // 操作按钮 this.createElement('div', { className: 'cwd-form-actions', @@ -175,6 +189,24 @@ export class CommentForm extends Component { this.empty(this.container); this.container.appendChild(root); this.renderEmotionPicker(root); + this.renderTurnstile(root); + } + + renderTurnstile(root) { + if (!this.props.turnstileSiteKey) { + return; + } + const container = root.querySelector('.cwd-turnstile-container'); + this.turnstileWidget = new TurnstileWidget(container, { + siteKey: this.props.turnstileSiteKey, + theme: this.props.turnstileTheme, + errorText: this.t('verifyFailed'), + onTokenChange: (token) => { + this.turnstileToken = token; + this.updateFormState(); + }, + }); + this.turnstileWidget.render(); } /** @@ -199,6 +231,13 @@ export class CommentForm extends Component { } updateProps(prevProps) { + if ( + this.props.turnstileSiteKey !== prevProps.turnstileSiteKey || + this.props.turnstileTheme !== prevProps.turnstileTheme + ) { + this.render(); + return; + } // 只在非提交状态时同步表单数据(避免覆盖用户正在输入的内容) if (!this.props.submitting && this.props.form !== prevProps.form) { // 保留当前正在输入的内容 @@ -233,7 +272,11 @@ export class CommentForm extends Component { const { formErrors, submitting } = this.props; const { localForm } = this.state; - const canSubmit = localForm.name.trim() && localForm.email.trim() && localForm.content.trim(); + const canSubmit = + localForm.name.trim() && + localForm.email.trim() && + localForm.content.trim() && + (!this.props.turnstileSiteKey || this.turnstileToken); // 更新提交按钮状态 const submitBtn = this.elements.root.querySelector('button[type="submit"]'); @@ -258,6 +301,7 @@ export class CommentForm extends Component { } else { previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview'); } + previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview); } // 更新输入框禁用状态 @@ -361,7 +405,42 @@ export class CommentForm extends Component { togglePreview() { this.state.showPreview = !this.state.showPreview; - this.render(); + this.updatePreviewState(); + } + + updatePreviewState() { + const root = this.elements.root; + if (!root) { + return; + } + + const previewBtn = root.querySelector('.cwd-btn-preview'); + if (previewBtn) { + previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview'); + previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview); + } + + let previewContainer = root.querySelector('.cwd-preview-container'); + if (!this.state.showPreview || !this.state.localForm.content) { + previewContainer?.remove(); + return; + } + + if (!previewContainer) { + previewContainer = this.createElement('div', { + className: 'cwd-preview-container', + children: [ + this.createElement('div', { + className: 'cwd-preview-content cwd-comment-content', + html: renderMarkdown(this.state.localForm.content), + }), + ], + }); + const actions = root.querySelector('.cwd-form-actions'); + root.insertBefore(previewContainer, actions?.nextSibling || null); + } else { + this.updatePreviewContent(this.state.localForm.content); + } } handleFieldChange(field, value) { @@ -414,7 +493,7 @@ export class CommentForm extends Component { } } - handleSubmit(e) { + async handleSubmit(e) { e.preventDefault(); const email = this.state.localForm.email?.trim(); const adminEmail = this.props.adminEmail; @@ -423,7 +502,14 @@ export class CommentForm extends Component { return; } if (this.props.onSubmit) { - this.props.onSubmit(this.state.localForm); + try { + const result = await this.props.onSubmit(this.state.localForm, this.turnstileToken); + if (result?.resetTurnstile !== false) { + this.turnstileWidget?.reset(); + } + } catch { + this.turnstileWidget?.reset(); + } } } @@ -466,4 +552,10 @@ export class CommentForm extends Component { }); this.modal.render(); } + + destroy() { + this.turnstileWidget?.destroy(); + this.turnstileWidget = null; + super.destroy(); + } } diff --git a/docs/widget/src/components/CommentItem.js b/docs/widget/src/components/CommentItem.js index 1b02d4b7..c29c2ed8 100644 --- a/docs/widget/src/components/CommentItem.js +++ b/docs/widget/src/components/CommentItem.js @@ -38,6 +38,10 @@ export class CommentItem extends Component { } render() { + this.replyEditor?.destroy(); + this.childCommentItems.forEach((item) => item.destroy()); + this.replyEditor = null; + this.childCommentItems = []; const { comment, isReply, adminEmail, adminBadge } = this.props; const isPinned = typeof comment.priority === 'number' && comment.priority > 1; const isReplying = this.props.replyingTo === comment.id; @@ -215,10 +219,12 @@ export class CommentItem extends Component { content: this.props.replyContent, error: this.props.replyError, submitting: this.props.submitting, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, currentUser: this.props.currentUser, onUpdateUserInfo: this.props.onUpdateUserInfo, onUpdate: (content) => this.handleUpdateReplyContent(content), - onSubmit: () => this.handleSubmitReply(), + onSubmit: (turnstileToken) => this.handleSubmitReply(turnstileToken), onCancel: () => this.handleCancelReply(), onClearError: () => this.handleClearReplyError(), placeholder: this.props.replyPlaceholder, @@ -245,6 +251,8 @@ export class CommentItem extends Component { replyContent: this.props.replyContent, replyError: this.props.replyError, submitting: this.props.submitting, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, currentUser: this.props.currentUser, onUpdateUserInfo: this.props.onUpdateUserInfo, // adminEmail 已移除 @@ -299,10 +307,12 @@ export class CommentItem extends Component { content: this.props.replyContent, error: this.props.replyError, submitting: this.props.submitting, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, currentUser: this.props.currentUser, onUpdateUserInfo: this.props.onUpdateUserInfo, onUpdate: (content) => this.handleUpdateReplyContent(content), - onSubmit: () => this.handleSubmitReply(), + onSubmit: (turnstileToken) => this.handleSubmitReply(turnstileToken), onCancel: () => this.handleCancelReply(), onClearError: () => this.handleClearReplyError(), placeholder: this.props.replyPlaceholder, @@ -313,6 +323,7 @@ export class CommentItem extends Component { this.replyEditor.focus(); } else if (!isReplying && replyContainer) { // 隐藏回复编辑器 + this.replyEditor?.destroy(); replyContainer.innerHTML = ''; this.replyEditor = null; } @@ -323,6 +334,8 @@ export class CommentItem extends Component { error: this.props.replyError, submitting: this.props.submitting, currentUser: this.props.currentUser, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, placeholder: this.props.replyPlaceholder, emotionGroups: this.props.emotionGroups, }); @@ -337,6 +350,8 @@ export class CommentItem extends Component { replyError: this.props.replyError, submitting: this.props.submitting, currentUser: this.props.currentUser, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, enableCommentLike: this.props.enableCommentLike, replyPlaceholder: this.props.replyPlaceholder, emotionGroups: this.props.emotionGroups, @@ -346,6 +361,46 @@ export class CommentItem extends Component { } } + updateCommentLikes(comment) { + this.props.comment = comment; + const button = this.elements.root?.querySelector('.cwd-comment-like-button'); + if (button) { + const liked = this.hasLiked(comment.id); + button.classList.toggle('cwd-comment-like-button-liked', liked); + button.setAttribute('aria-label', liked ? '取消点赞' : '点赞'); + + const icon = button.querySelector('.cwd-comment-like-icon'); + if (icon) { + icon.setAttribute('fill', liked ? 'currentColor' : 'none'); + } + + const likeCount = + typeof comment.likes === 'number' && Number.isFinite(comment.likes) && comment.likes >= 0 + ? comment.likes + : 0; + let countElement = button.querySelector('.cwd-comment-like-count'); + if (likeCount >= 1) { + if (!countElement) { + countElement = this.createTextElement('span', String(likeCount), 'cwd-comment-like-count'); + button.appendChild(countElement); + } else { + countElement.textContent = String(likeCount); + } + } else { + countElement?.remove(); + } + } + + const replies = Array.isArray(comment.replies) ? comment.replies : []; + const repliesById = new Map(replies.map((reply) => [reply.id, reply])); + this.childCommentItems.forEach((childItem) => { + const nextReply = repliesById.get(childItem.props.comment.id); + if (nextReply) { + childItem.updateCommentLikes(nextReply); + } + }); + } + handleReply() { if (this.props.onReply) { this.props.onReply(this.props.comment.id); @@ -452,9 +507,9 @@ export class CommentItem extends Component { return likedComments.has(String(commentId)); } - handleSubmitReply() { + handleSubmitReply(turnstileToken) { if (this.props.onSubmitReply) { - this.props.onSubmitReply(this.props.comment.id); + return this.props.onSubmitReply(this.props.comment.id, turnstileToken); } } @@ -475,4 +530,12 @@ export class CommentItem extends Component { this.props.onClearReplyError(); } } + + destroy() { + this.replyEditor?.destroy(); + this.childCommentItems.forEach((item) => item.destroy()); + this.replyEditor = null; + this.childCommentItems = []; + super.destroy(); + } } diff --git a/docs/widget/src/components/CommentList.js b/docs/widget/src/components/CommentList.js index 0d235c60..7843adc1 100644 --- a/docs/widget/src/components/CommentList.js +++ b/docs/widget/src/components/CommentList.js @@ -6,6 +6,7 @@ import { Component } from './Component.js'; import { CommentItem } from './CommentItem.js'; import { Loading } from './Loading.js'; import { Pagination } from './Pagination.js'; +import { canUpdateLikesInPlace, updateCommentLikesInPlace } from './commentLikeUpdates.js'; export class CommentList extends Component { /** @@ -44,6 +45,8 @@ export class CommentList extends Component { render() { const { comments, loading, error, currentPage, totalPages } = this.props; + this.commentItems.forEach((commentItem) => commentItem.destroy()); + this.commentItems.clear(); // 清空容器 this.empty(this.container); @@ -87,9 +90,6 @@ export class CommentList extends Component { className: 'cwd-comments' }); - // 清空旧的缓存 - this.commentItems.clear(); - comments.forEach((comment, index) => { const commentItem = new CommentItem(commentsContainer, { comment, @@ -104,8 +104,10 @@ export class CommentList extends Component { enableCommentLike: this.props.enableCommentLike, replyPlaceholder: this.props.replyPlaceholder, emotionGroups: this.props.emotionGroups, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, onReply: (commentId) => this.handleReply(commentId), - onSubmitReply: (commentId) => this.handleSubmitReply(commentId), + onSubmitReply: (commentId, turnstileToken) => this.handleSubmitReply(commentId, turnstileToken), onCancelReply: () => this.handleCancelReply(), onUpdateReplyContent: (content) => this.handleUpdateReplyContent(content), onClearReplyError: () => this.handleClearReplyError(), @@ -157,17 +159,27 @@ export class CommentList extends Component { return; } - // 如果评论列表变化,重新渲染 + // 点赞只更新对应评论,保留已创建的回复编辑器和 Turnstile challenge。 if (this.props.comments !== prevProps.comments) { - this.render(); - return; + const canUpdateInPlace = + canUpdateLikesInPlace(prevProps.comments, this.props.comments) && + this.props.comments.every((comment) => this.commentItems.has(comment.id)); + + if (canUpdateInPlace) { + updateCommentLikesInPlace(this.commentItems, this.props.comments); + } else { + this.render(); + return; + } } // 如果只是回复状态变化,局部更新 CommentItem 而不是完全重新渲染 if (this.props.replyingTo !== prevProps.replyingTo || this.props.replyError !== prevProps.replyError || this.props.submitting !== prevProps.submitting || - this.props.currentUser !== prevProps.currentUser) { + this.props.currentUser !== prevProps.currentUser || + this.props.turnstileSiteKey !== prevProps.turnstileSiteKey || + this.props.turnstileTheme !== prevProps.turnstileTheme) { // 局部更新所有 CommentItem this.commentItems.forEach((commentItem) => { commentItem.setProps({ @@ -178,6 +190,8 @@ export class CommentList extends Component { currentUser: this.props.currentUser, enableCommentLike: this.props.enableCommentLike, emotionGroups: this.props.emotionGroups, + turnstileSiteKey: this.props.turnstileSiteKey, + turnstileTheme: this.props.turnstileTheme, onLikeComment: (commentId, isLike) => this.handleLikeComment(commentId, isLike) }); }); @@ -210,9 +224,9 @@ export class CommentList extends Component { } } - handleSubmitReply(commentId) { + handleSubmitReply(commentId, turnstileToken) { if (this.props.onSubmitReply) { - this.props.onSubmitReply(commentId); + return this.props.onSubmitReply(commentId, turnstileToken); } } @@ -240,6 +254,12 @@ export class CommentList extends Component { } } + destroy() { + this.commentItems.forEach((commentItem) => commentItem.destroy()); + this.commentItems.clear(); + super.destroy(); + } + handlePrevPage() { if (this.props.onPrevPage) { this.props.onPrevPage(); diff --git a/docs/widget/src/components/ReplyEditor.js b/docs/widget/src/components/ReplyEditor.js index 91309dea..85b580e5 100644 --- a/docs/widget/src/components/ReplyEditor.js +++ b/docs/widget/src/components/ReplyEditor.js @@ -4,6 +4,7 @@ import { Component } from './Component.js'; import { EmotionPicker } from './EmotionPicker.js'; +import { TurnstileWidget } from './TurnstileWidget.js'; import { insertTextAtCursor } from '../utils/emotions.js'; import { renderMarkdown } from '../utils/markdown.js'; @@ -32,9 +33,14 @@ export class ReplyEditor extends Component { showPreview: false, }; this.emotionPicker = null; + this.turnstileWidget = null; + this.turnstileToken = ''; } render() { + this.turnstileWidget?.destroy(); + this.turnstileWidget = null; + this.turnstileToken = ''; const { currentUser } = this.props; const { showUserInfo } = this.state; const placeholderText = this.props.placeholder || ''; @@ -110,6 +116,10 @@ export class ReplyEditor extends Component { ] : []), + ...(this.props.turnstileSiteKey + ? [this.createElement('div', { className: 'cwd-turnstile-container cwd-reply-turnstile-container' })] + : []), + // 操作按钮 this.createElement('div', { className: 'cwd-reply-actions', @@ -127,13 +137,16 @@ export class ReplyEditor extends Component { className: 'cwd-btn cwd-btn-primary cwd-btn-small', attributes: { type: 'button', - disabled: this.props.submitting || !this.state.content.trim(), + disabled: + this.props.submitting || + !this.state.content.trim() || + (!!this.props.turnstileSiteKey && !this.turnstileToken), onClick: () => this.handleSubmit(), }, text: this.props.submitting ? this.t('submitting') : this.t('submit'), }), this.createElement('button', { - className: 'cwd-btn cwd-btn-secondary cwd-btn-small', + className: 'cwd-btn cwd-btn-secondary cwd-btn-small cwd-btn-cancel', attributes: { type: 'button', disabled: this.props.submitting, @@ -172,6 +185,24 @@ export class ReplyEditor extends Component { this.empty(this.container); this.container.appendChild(root); this.renderEmotionPicker(root); + this.renderTurnstile(root); + } + + renderTurnstile(root) { + if (!this.props.turnstileSiteKey) { + return; + } + const container = root.querySelector('.cwd-turnstile-container'); + this.turnstileWidget = new TurnstileWidget(container, { + siteKey: this.props.turnstileSiteKey, + theme: this.props.turnstileTheme, + errorText: this.t('verifyFailed'), + onTokenChange: (token) => { + this.turnstileToken = token; + this.updateActionState(); + }, + }); + this.turnstileWidget.render(); } /** @@ -196,30 +227,30 @@ export class ReplyEditor extends Component { } updateProps(prevProps) { - // 如果外部传入的 content 变化,更新内部状态 - if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) { - this.state.content = this.props.content; - this.render(); - return; - } - - // 如果用户信息变化,重新渲染 - if (JSON.stringify(this.props.currentUser) !== JSON.stringify(prevProps?.currentUser)) { + if ( + this.props.turnstileSiteKey !== prevProps?.turnstileSiteKey || + this.props.turnstileTheme !== prevProps?.turnstileTheme + ) { this.render(); return; } - - // 如果有错误显示/隐藏变化,重新渲染 - if (this.props.error !== prevProps?.error) { - this.render(); - return; + // 如果外部传入的 content 变化,更新内部状态 + if (this.props.content !== this.state.content && this.props.content !== prevProps?.content) { + this.state.content = this.props.content; + const textarea = this.elements.root?.querySelector('.cwd-reply-textarea'); + if (textarea) { + textarea.value = this.state.content; + } + if (!this.state.content.trim()) { + this.state.showPreview = false; + } + this.updatePreviewState(); } - // 如果 submitting 状态变化,重新渲染 - if (this.props.submitting !== prevProps?.submitting) { - this.render(); - return; - } + this.updateUserInfoFields(); + this.updateErrorState(); + this.updateSubmittingState(); + this.updateActionState(); } handleTextareaKeydown(e) { @@ -230,7 +261,7 @@ export class ReplyEditor extends Component { togglePreview() { this.state.showPreview = !this.state.showPreview; - this.render(); + this.updatePreviewState(); } handleInput(e) { @@ -239,7 +270,10 @@ export class ReplyEditor extends Component { // 更新提交按钮的禁用状态 const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary'); if (submitBtn) { - submitBtn.disabled = this.props.submitting || !this.state.content.trim(); + submitBtn.disabled = + this.props.submitting || + !this.state.content.trim() || + (!!this.props.turnstileSiteKey && !this.turnstileToken); } // 更新预览按钮的禁用状态 @@ -286,12 +320,114 @@ export class ReplyEditor extends Component { updateActionState() { const submitBtn = this.elements.root?.querySelector('.cwd-btn-primary'); if (submitBtn) { - submitBtn.disabled = this.props.submitting || !this.state.content.trim(); + submitBtn.disabled = + this.props.submitting || + !this.state.content.trim() || + (!!this.props.turnstileSiteKey && !this.turnstileToken); } const previewBtn = this.elements.root?.querySelector('.cwd-btn-preview'); if (previewBtn) { previewBtn.disabled = this.props.submitting || !this.state.content.trim(); + previewBtn.textContent = this.state.showPreview ? this.t('close') : this.t('preview'); + previewBtn.classList.toggle('cwd-btn-active', this.state.showPreview); + } + } + + updatePreviewState() { + const root = this.elements.root; + if (!root) { + return; + } + + this.updateActionState(); + let previewContainer = root.querySelector('.cwd-preview-container'); + if (!this.state.showPreview || !this.state.content) { + previewContainer?.remove(); + return; + } + + if (!previewContainer) { + previewContainer = this.createElement('div', { + className: 'cwd-preview-container', + children: [ + this.createElement('div', { + className: 'cwd-preview-content cwd-comment-content', + html: renderMarkdown(this.state.content), + }), + ], + }); + const actions = root.querySelector('.cwd-reply-actions'); + root.insertBefore(previewContainer, actions?.nextSibling || null); + } else { + this.updatePreviewContent(this.state.content); + } + } + + updateUserInfoFields() { + const currentUser = this.props.currentUser || {}; + for (const field of ['name', 'email', 'url']) { + const input = this.elements.root?.querySelector(`[data-cwd-user-field="${field}"]`); + if (input && (typeof document === 'undefined' || input !== document.activeElement)) { + input.value = currentUser[field] || ''; + } + } + } + + updateErrorState() { + const root = this.elements.root; + if (!root) { + return; + } + + let errorElement = root.querySelector('.cwd-error-inline'); + if (!this.props.error) { + errorElement?.remove(); + return; + } + + if (!errorElement) { + errorElement = this.createElement('div', { + className: 'cwd-error-inline cwd-error-small', + children: [ + this.createTextElement('span', this.props.error), + this.createElement('button', { + className: 'cwd-error-close', + attributes: { + type: 'button', + onClick: () => this.handleClearError(), + }, + text: '✕', + }), + ], + }); + const nextElement = root.querySelector('.cwd-turnstile-container') || root.querySelector('.cwd-reply-actions'); + root.insertBefore(errorElement, nextElement); + return; + } + + const message = errorElement.querySelector('span'); + if (message) { + message.textContent = this.props.error; + } + } + + updateSubmittingState() { + const root = this.elements.root; + if (!root) { + return; + } + + root.querySelectorAll('input, textarea').forEach((field) => { + field.disabled = !!this.props.submitting; + }); + const submitBtn = root.querySelector('.cwd-btn-primary'); + if (submitBtn) { + submitBtn.textContent = this.props.submitting ? this.t('submitting') : this.t('submit'); + } + const cancelBtn = root.querySelector('.cwd-btn-cancel'); + if (cancelBtn) { + cancelBtn.disabled = !!this.props.submitting; } } @@ -302,9 +438,16 @@ export class ReplyEditor extends Component { } } - handleSubmit() { + async handleSubmit() { if (this.props.onSubmit) { - this.props.onSubmit(); + try { + const result = await this.props.onSubmit(this.turnstileToken); + if (result?.resetTurnstile !== false) { + this.turnstileWidget?.reset(); + } + } catch { + this.turnstileWidget?.reset(); + } } } @@ -367,6 +510,7 @@ export class ReplyEditor extends Component { placeholder, value: value || '', disabled: this.props.submitting, + dataset: { cwdUserField: field }, onInput: (e) => this.handleUserInfoChange(field, e.target.value), onKeydown: (e) => this.handleTextareaKeydown(e), }, @@ -374,4 +518,10 @@ export class ReplyEditor extends Component { ], }); } + + destroy() { + this.turnstileWidget?.destroy(); + this.turnstileWidget = null; + super.destroy(); + } } diff --git a/docs/widget/src/components/TurnstileWidget.js b/docs/widget/src/components/TurnstileWidget.js new file mode 100644 index 00000000..e52c9800 --- /dev/null +++ b/docs/widget/src/components/TurnstileWidget.js @@ -0,0 +1,124 @@ +const TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; + +let turnstileScriptPromise = null; + +function waitForTurnstile(timeoutMs = 10000) { + return new Promise((resolve, reject) => { + const startedAt = Date.now(); + const timer = window.setInterval(() => { + if (window.turnstile && typeof window.turnstile.render === 'function') { + window.clearInterval(timer); + resolve(window.turnstile); + return; + } + if (Date.now() - startedAt >= timeoutMs) { + window.clearInterval(timer); + reject(new Error('Turnstile script timed out')); + } + }, 50); + }); +} + +function loadTurnstileScript() { + if (window.turnstile && typeof window.turnstile.render === 'function') { + return Promise.resolve(window.turnstile); + } + if (turnstileScriptPromise) { + return turnstileScriptPromise; + } + + turnstileScriptPromise = new Promise((resolve, reject) => { + let script = document.querySelector('script[data-cwd-turnstile]'); + if (!script) { + script = document.createElement('script'); + script.src = TURNSTILE_SCRIPT_URL; + script.async = true; + script.defer = true; + script.dataset.cwdTurnstile = 'true'; + document.head.appendChild(script); + } + + const resolveWhenReady = () => { + waitForTurnstile().then(resolve).catch(reject); + }; + script.addEventListener('load', resolveWhenReady, { once: true }); + script.addEventListener('error', () => reject(new Error('Failed to load Turnstile script')), { once: true }); + + if (window.turnstile) { + resolveWhenReady(); + } + }).catch((error) => { + turnstileScriptPromise = null; + throw error; + }); + + return turnstileScriptPromise; +} + +/** + * Explicitly renders a Cloudflare Turnstile challenge inside the widget Shadow DOM. + */ +export class TurnstileWidget { + constructor(container, options = {}) { + this.container = container; + this.options = options; + this.widgetId = null; + this.token = ''; + this.destroyed = false; + } + + async render() { + if (!this.container || !this.options.siteKey || this.destroyed) { + return; + } + + try { + const turnstile = await loadTurnstileScript(); + if (this.destroyed || !this.container.isConnected) { + return; + } + this.widgetId = turnstile.render(this.container, { + sitekey: this.options.siteKey, + theme: this.options.theme === 'dark' ? 'dark' : 'light', + size: 'flexible', + action: 'comment', + callback: (token) => this.setToken(token), + 'expired-callback': () => this.setToken(''), + 'timeout-callback': () => this.setToken(''), + 'error-callback': () => this.setToken(''), + }); + } catch { + if (!this.destroyed && this.container) { + this.container.textContent = this.options.errorText || ''; + this.container.classList.add('cwd-turnstile-error'); + } + } + } + + setToken(token) { + this.token = typeof token === 'string' ? token : ''; + if (this.options.onTokenChange) { + this.options.onTokenChange(this.token); + } + } + + getToken() { + return this.token; + } + + reset() { + this.setToken(''); + if (this.widgetId !== null && window.turnstile && typeof window.turnstile.reset === 'function') { + window.turnstile.reset(this.widgetId); + } + } + + destroy() { + this.destroyed = true; + this.setToken(''); + if (this.widgetId !== null && window.turnstile && typeof window.turnstile.remove === 'function') { + window.turnstile.remove(this.widgetId); + } + this.widgetId = null; + } +} diff --git a/docs/widget/src/components/commentLikeUpdates.js b/docs/widget/src/components/commentLikeUpdates.js new file mode 100644 index 00000000..96d4842c --- /dev/null +++ b/docs/widget/src/components/commentLikeUpdates.js @@ -0,0 +1,30 @@ +export function canUpdateLikesInPlace(previousComments, nextComments) { + if (!Array.isArray(previousComments) || !Array.isArray(nextComments) || previousComments.length !== nextComments.length) { + return false; + } + + return previousComments.every((previousComment, index) => { + const nextComment = nextComments[index]; + if (!previousComment || !nextComment || previousComment.id !== nextComment.id) { + return false; + } + + const keys = new Set([...Object.keys(previousComment), ...Object.keys(nextComment)]); + for (const key of keys) { + if (key === 'likes' || key === 'replies') { + continue; + } + if (!Object.is(previousComment[key], nextComment[key])) { + return false; + } + } + + return canUpdateLikesInPlace(previousComment.replies || [], nextComment.replies || []); + }); +} + +export function updateCommentLikesInPlace(commentItems, comments) { + comments.forEach((comment) => { + commentItems.get(comment.id).updateCommentLikes(comment); + }); +} diff --git a/docs/widget/src/components/turnstile-lifecycle.test.js b/docs/widget/src/components/turnstile-lifecycle.test.js new file mode 100644 index 00000000..b796ab82 --- /dev/null +++ b/docs/widget/src/components/turnstile-lifecycle.test.js @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CommentForm } from './CommentForm.js'; +import { ReplyEditor } from './ReplyEditor.js'; +import { canUpdateLikesInPlace, updateCommentLikesInPlace } from './commentLikeUpdates.js'; + +test('preview toggles update in place without rendering a new challenge', () => { + for (const ComponentClass of [CommentForm, ReplyEditor]) { + const component = Object.create(ComponentClass.prototype); + component.state = { showPreview: false }; + let renders = 0; + let previewUpdates = 0; + component.render = () => renders++; + component.updatePreviewState = () => previewUpdates++; + + component.togglePreview(); + + assert.equal(component.state.showPreview, true); + assert.equal(previewUpdates, 1); + assert.equal(renders, 0); + } +}); + +test('reply prop updates do not render a new challenge', () => { + const editor = Object.create(ReplyEditor.prototype); + editor.props = { + turnstileSiteKey: 'site-key', + turnstileTheme: 'light', + content: 'draft', + currentUser: { name: 'Reader' }, + error: 'failed', + submitting: true, + }; + editor.state = { content: 'draft', showPreview: false }; + editor.elements = {}; + let renders = 0; + editor.render = () => renders++; + editor.updateUserInfoFields = () => {}; + editor.updateErrorState = () => {}; + editor.updateSubmittingState = () => {}; + editor.updateActionState = () => {}; + + editor.updateProps({ + turnstileSiteKey: 'site-key', + turnstileTheme: 'light', + content: 'draft', + currentUser: {}, + error: null, + submitting: false, + }); + + assert.equal(renders, 0); +}); + +test('a pre-Siteverify rejection preserves the current challenge', async () => { + for (const ComponentClass of [CommentForm, ReplyEditor]) { + const component = Object.create(ComponentClass.prototype); + component.turnstileToken = 'token'; + let resets = 0; + component.turnstileWidget = { reset: () => resets++ }; + component.props = { + onSubmit: async () => ({ success: false, resetTurnstile: false }), + }; + component.state = { localForm: { email: '' } }; + + if (ComponentClass === CommentForm) { + await component.handleSubmit({ preventDefault() {} }); + } else { + await component.handleSubmit(); + } + + assert.equal(resets, 0); + } +}); + +test('like-only comment updates preserve the active reply challenge', () => { + const previousComments = [ + { + id: 1, + name: 'Reader', + likes: 0, + replies: [{ id: 2, name: 'Author', likes: 0 }], + }, + ]; + const nextComments = [ + { + ...previousComments[0], + replies: [{ ...previousComments[0].replies[0], likes: 1 }], + }, + ]; + const activeReplyEditor = { turnstileToken: 'solved-token' }; + const commentItem = { + replyEditor: activeReplyEditor, + updateCommentLikes(comment) { + this.comment = comment; + }, + }; + const commentItems = new Map([[1, commentItem]]); + + assert.equal(canUpdateLikesInPlace(previousComments, nextComments), true); + updateCommentLikesInPlace(commentItems, nextComments); + + assert.equal(commentItem.comment, nextComments[0]); + assert.equal(commentItem.replyEditor, activeReplyEditor); + assert.equal(commentItem.replyEditor.turnstileToken, 'solved-token'); +}); diff --git a/docs/widget/src/core/CWDComments.js b/docs/widget/src/core/CWDComments.js index 65bb8d8c..d320590b 100644 --- a/docs/widget/src/core/CWDComments.js +++ b/docs/widget/src/core/CWDComments.js @@ -23,6 +23,7 @@ export class CWDComments { * @param {'light'|'dark'} [config.theme] - 主题(可选) * @param {number} [config.pageSize] - 每页评论数(可选,默认 20) * @param {string|Object} [config.emotionJson] - 前端表情 JSON 文件链接,留空则不显示表情按钮 + * @param {string} [config.turnstileSiteKey] - Cloudflare Turnstile site key(通常由后端自动下发) * * 以下字段由组件自动推导或从后端读取,无需通过 config 传入: * - postSlug:window.location.origin + window.location.pathname @@ -126,6 +127,8 @@ export class CWDComments { enableCommentLike: typeof data.enableCommentLike === 'boolean' ? data.enableCommentLike : true, enableArticleLike: typeof data.enableArticleLike === 'boolean' ? data.enableArticleLike : true, enableImageLightbox: typeof data.enableImageLightbox === 'boolean' ? data.enableImageLightbox : false, + requireReview: !!data.requireReview, + turnstileSiteKey: typeof data.turnstileSiteKey === 'string' ? data.turnstileSiteKey : '', commentPlaceholder: typeof data.commentPlaceholder === 'string' ? data.commentPlaceholder : undefined, widgetLanguage: typeof data.widgetLanguage === 'string' ? data.widgetLanguage : undefined, @@ -223,6 +226,7 @@ export class CWDComments { this.config.adminBadge = serverConfig.adminBadge; } this.config.requireReview = !!serverConfig.requireReview; + this.config.turnstileSiteKey = serverConfig.turnstileSiteKey || this.config.turnstileSiteKey || ''; this.config.enableCommentLike = serverConfig.enableCommentLike; this.config.enableArticleLike = serverConfig.enableArticleLike; this.config.enableImageLightbox = serverConfig.enableImageLightbox; @@ -423,12 +427,14 @@ export class CWDComments { form: state.form, formErrors: state.formErrors, submitting: state.submitting, - onSubmit: () => this._handleSubmit(), + onSubmit: (_form, turnstileToken) => this._handleSubmit(turnstileToken), onFieldChange: (field, value) => this.store.updateFormField(field, value), adminEmail: this.config.adminEmail, onVerifyAdmin: (key) => this.api.verifyAdminKey(key), placeholder: this.config.commentPlaceholder, emotionGroups: this.emotionGroups, + turnstileSiteKey: this.config.turnstileSiteKey, + turnstileTheme: this.config.theme, t: this.t }); this.commentForm.render(); @@ -473,9 +479,11 @@ export class CWDComments { // adminEmail 已移除,前端展示改用 isAdmin 字段 adminBadge: this.config.adminBadge, enableCommentLike: this.config.enableCommentLike !== false, + turnstileSiteKey: this.config.turnstileSiteKey, + turnstileTheme: this.config.theme, onRetry: () => this.store.loadComments(), onReply: (commentId) => this.store.startReply(commentId), - onSubmitReply: (commentId) => this.store.submitReply(commentId), + onSubmitReply: (commentId, turnstileToken) => this.store.submitReply(commentId, turnstileToken), onCancelReply: () => this.store.cancelReply(), onUpdateReplyContent: (content) => this.store.updateReplyContent(content), onClearReplyError: () => this.store.clearReplyError(), @@ -528,6 +536,8 @@ export class CWDComments { submitting: state.submitting, adminEmail: this.config.adminEmail, emotionGroups: this.emotionGroups, + turnstileSiteKey: this.config.turnstileSiteKey, + turnstileTheme: this.config.theme, }); } @@ -608,6 +618,8 @@ export class CWDComments { submitting: state.submitting, currentUser: state.form, emotionGroups: this.emotionGroups, + turnstileSiteKey: this.config.turnstileSiteKey, + turnstileTheme: this.config.theme, }); } @@ -627,9 +639,9 @@ export class CWDComments { * 处理评论提交 * @private */ - async _handleSubmit() { - const success = await this.store.submitNewComment(); - if (success) { + async _handleSubmit(turnstileToken) { + const result = await this.store.submitNewComment(turnstileToken); + if (result.success) { // 表单内容已在 store 中清空 // 更新表单组件 if (this.commentForm) { @@ -638,6 +650,7 @@ export class CWDComments { this.commentForm.render(); } } + return result; } /** @@ -664,6 +677,8 @@ export class CWDComments { // 更新主题 if (newConfig.theme && this.mountPoint) { this.mountPoint.setAttribute('data-theme', newConfig.theme); + this.commentForm?.setProps({ turnstileTheme: newConfig.theme }); + this.commentList?.setProps({ turnstileTheme: newConfig.theme }); } const shouldReload = diff --git a/docs/widget/src/core/api.js b/docs/widget/src/core/api.js index f3d0ebad..5c4f2317 100644 --- a/docs/widget/src/core/api.js +++ b/docs/widget/src/core/api.js @@ -87,18 +87,23 @@ export function createApiClient(config) { content: data.content, parent_id: data.parentId, adminToken: data.adminToken, + turnstileToken: data.turnstileToken, site_id: config.siteId }), }); if (!response.ok) { - // Try to parse error message - let msg = response.statusText; - try { - const json = await response.json(); - if (json.message) msg = json.message; - } catch (e) {} - throw new Error(msg); + let msg = response.statusText; + let turnstileConsumed = true; + try { + const json = await response.json(); + if (json.message) msg = json.message; + turnstileConsumed = json.turnstileConsumed !== false; + } catch (e) {} + const error = new Error(msg); + error.status = response.status; + error.turnstileConsumed = turnstileConsumed; + throw error; } return response.json(); } diff --git a/docs/widget/src/core/api.test.js b/docs/widget/src/core/api.test.js new file mode 100644 index 00000000..be89de40 --- /dev/null +++ b/docs/widget/src/core/api.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createApiClient } from './api.js'; + +test('submitComment exposes whether Turnstile was consumed', async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + globalThis.fetch = async () => + new Response( + JSON.stringify({ + message: '评论频繁,等10s后再试', + turnstileConsumed: false, + }), + { status: 429, statusText: 'Too Many Requests' } + ); + + const api = createApiClient({ + apiBaseUrl: 'https://comments.example.com', + postSlug: '/post', + }); + + await assert.rejects( + api.submitComment({ + name: 'Reader', + email: 'reader@example.com', + content: 'hello', + turnstileToken: 'token', + }), + (error) => { + assert.equal(error.status, 429); + assert.equal(error.turnstileConsumed, false); + return true; + } + ); +}); diff --git a/docs/widget/src/core/store.js b/docs/widget/src/core/store.js index 391fb594..4dafa692 100644 --- a/docs/widget/src/core/store.js +++ b/docs/widget/src/core/store.js @@ -259,7 +259,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom /** * 提交评论 */ - async function submitNewComment() { + async function submitNewComment(turnstileToken = '') { const state = store.getState(); const form = state.form; @@ -270,7 +270,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom store.setState({ formErrors: validation.errors, }); - return false; + return { success: false, resetTurnstile: false }; } // 清空错误 @@ -287,7 +287,8 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom email: form.email, url: form.url, content: form.content, - adminToken: auth.getToken() // Add token if exists + adminToken: auth.getToken(), // Add token if exists + turnstileToken, }); const successMessage = @@ -305,14 +306,17 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom // 重新加载评论 await loadComments(state.pagination.page); - return true; + return { success: true, resetTurnstile: true }; } catch (e) { store.setState({ error: e instanceof Error ? e.message : '提交评论失败', submitting: false, successMessage: '', }); - return false; + return { + success: false, + resetTurnstile: !e || e.turnstileConsumed !== false, + }; } } @@ -320,12 +324,12 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom * 提交回复 * @param {number} parentId - 父评论 ID */ - async function submitReply(parentId) { + async function submitReply(parentId, turnstileToken = '') { const state = store.getState(); // 验证回复内容 if (!state.replyContent.trim()) { - return false; + return { success: false, resetTurnstile: false }; } // 验证用户信息 @@ -336,7 +340,7 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom store.setState({ replyError: errorMessages, }); - return false; + return { success: false, resetTurnstile: false }; } store.setState({ @@ -352,7 +356,8 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom url: state.form.url, content: state.replyContent, parentId, - adminToken: auth.getToken() + adminToken: auth.getToken(), + turnstileToken, }); // 清空回复内容并关闭回复框 @@ -364,13 +369,16 @@ export function createCommentStore(config, fetchComments, submitComment, likeCom // 重新加载评论 await loadComments(state.pagination.page); - return true; + return { success: true, resetTurnstile: true }; } catch (e) { store.setState({ error: e instanceof Error ? e.message : '提交回复失败', submitting: false, }); - return false; + return { + success: false, + resetTurnstile: !e || e.turnstileConsumed !== false, + }; } } diff --git a/docs/widget/src/index.d.ts b/docs/widget/src/index.d.ts index 4e0aa388..6cc220cc 100644 --- a/docs/widget/src/index.d.ts +++ b/docs/widget/src/index.d.ts @@ -76,6 +76,11 @@ export interface CWDCommentsConfig { * Custom OwO emotion JSON for the emotion picker. Empty values hide the emotion button. */ emotionJson?: string | Record; + + /** + * Cloudflare Turnstile site key. Normally loaded from the CWD API. + */ + turnstileSiteKey?: string; } export class CWDComments { diff --git a/docs/widget/src/styles/main.css b/docs/widget/src/styles/main.css index f8c4d59d..fa2bda91 100644 --- a/docs/widget/src/styles/main.css +++ b/docs/widget/src/styles/main.css @@ -360,6 +360,25 @@ gap: 10px; } +.cwd-turnstile-container { + display: flex; + justify-content: flex-end; + width: 100%; + min-height: 65px; + margin-top: 16px; +} + +.cwd-reply-turnstile-container { + margin-top: 12px; +} + +.cwd-turnstile-error { + align-items: center; + min-height: 40px; + color: var(--cwd-error, #cf222e); + font-size: 12px; +} + .cwd-btn { padding: 8px 16px; font-size: 14px;