接入文档 / 实践

从一张表单开始

一个 HTML 页面、一段 Node.js 后端。把“显示控件”到“允许提交”的过程接起来。

先确认两件事

在控制台创建站点,填入显示表单的域名,例如 example.com。本地测试还要加入 127.0.0.1。域名不带协议或路径。

sitekey 可以放在网页上;secret 只留在服务端。下面的示例不依赖框架,需要带有内置 fetch 的 Node.js 环境。

1. 页面收集留言和验证结果

控件放在表单内部。访客完成计算后,它会生成名为 cap-response 的隐藏字段,随普通表单一起提交。

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>cap 表单示例</title>
<script src="https://cap.bug.im/v1.js" defer></script>
<form method="post" action="/contact">
  <label>留言 <textarea name="message" maxlength="2000" required></textarea></label>
  <div class="cap-captcha" data-sitekey="YOUR_SITEKEY"></div>
  <button type="submit">提交</button>
</form>
</html>

2. 后端校验,再处理业务

把两个文件放在同一目录,将 HTML 中的 YOUR_SITEKEY 换成你的 key。把 secret 放入服务端环境变量 CAP_SECRET,然后运行 node server.mjs,打开 http://127.0.0.1:3000。

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';

const secret = process.env.CAP_SECRET;
if (!secret) throw new Error('Set CAP_SECRET on the server.');
const html = await readFile(fileURLToPath(new URL('./index.html', import.meta.url)));
createServer(async (req, res) => {
  const send = (status, text, type = 'text/plain; charset=utf-8') => {
    res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
    res.end(text);
  };
  if (req.method === 'GET' && req.url === '/') return send(200, html, 'text/html; charset=utf-8');
  if (req.method !== 'POST' || req.url !== '/contact') return send(404, 'Not found');
  if (!req.headers['content-type']?.startsWith('application/x-www-form-urlencoded')) return send(415, 'Unsupported form type');
  try {
    const chunks = [];
    let bytes = 0;
    for await (const chunk of req) {
      bytes += chunk.length;
      if (bytes > 65536) return send(413, 'Form too large');
      chunks.push(chunk);
    }
    const form = new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
    const message = form.get('message')?.trim();
    if (!message || message.length > 2000) return send(400, '请填写 1–2000 字留言。');
    const response = form.get('cap-response');
    if (!response) return send(403, '请先完成验证。');
    const check = await fetch('https://cap.bug.im/v1/siteverify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ secret, response }),
      signal: AbortSignal.timeout(8000),
    });
    const result = await check.json();
    if (!check.ok || result.success !== true || result.degraded === true) return send(403, '验证未通过,请返回并重新验证。');
    // Persist the validated message here, then return success.
    return send(200, '验证通过。此示例未保存留言。');
  } catch {
    return send(503, '暂时无法校验,请返回并重新验证。');
  }
}).listen(3000, '127.0.0.1');
console.log('Open http://127.0.0.1:3000');

这个示例只返回校验结果,不保存留言。真正上线时,在校验成功后的注释位置加入入库、发信等业务,并补齐业务限流与 CSRF 防护。不要仅凭前端显示“已通过”就接受提交。

重试要重新验证

token 过期或被使用过都不能再次提交。网络异常时,也可能已经在服务端消费了 token;返回页面后重新验证再试。示例对降级放行也选择拒绝,可按业务风险决定是否接受 degraded。

← 返回接入文档 · 体验验证