Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cwd-api/src/api/public/postComment.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 25 additions & 15 deletions cwd-api/src/api/public/postComment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<script[\s\S]*?<\/script>/g, "");
Expand All @@ -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;
Expand All @@ -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<string>('value');
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions cwd-api/src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion cwd-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
93 changes: 93 additions & 0 deletions cwd-api/src/utils/turnstile.spec.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
100 changes: 100 additions & 0 deletions cwd-api/src/utils/turnstile.ts
Original file line number Diff line number Diff line change
@@ -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<Bindings, 'TURNSTILE_SECRET_KEY' | 'TURNSTILE_ALLOWED_HOSTNAMES'>,
token: unknown,
remoteIp?: string,
fetcher: typeof fetch = fetch
): Promise<TurnstileVerificationResult> {
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 };
}
27 changes: 26 additions & 1 deletion docs/api/public/comments.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand All @@ -173,6 +174,7 @@ POST /api/comments
| `content` | string | 是 | 评论内容,内部会过滤 `<script>...</script>` 片段 |
| `parent_id` | number | 否 | 父评论 ID,用于回复功能;缺省或 `null` 表示根评论 |
| `adminToken` | string | 否 | 管理员评论密钥,博主发布评论时需要先通过 `/api/verify-admin` 验证密钥后将密钥传入此字段,评论将直接通过且不受审核设置影响 |
| `turnstileToken` | string | 启用 Turnstile 时是 | Cloudflare Turnstile 返回的一次性验证 token |

**请求头说明:**

Expand Down Expand Up @@ -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`
Expand Down
Loading