@broberg/形式-翻转
垃圾邮件保护 公开形式 broberg.ai车队的原始:蜂窝检测,一个在流程中的IP速率限制器,和Cloudflare Turnstile服务器侧校验——外加Preact部件钩和Hono中件. 从 webhouse/cms(无头核心)与 xrt81'铅形式 (Preact/Hono e2e).
npm i @broberg/forms-turnstile # exact-pin for prod-auth deps
核心项目@broberg/forms-turnstile 编号 : @broberg/forms-turnstile/server(中文(简体) )
框架不可知性, node:crypto 只有这个.
import { applySpamGauntlet, hashIp, getSitekeyResponse } from "@broberg/forms-turnstile/server";
const ipHash = hashIp(clientIp); // GDPR-friendly — never store the raw IP
const result = await applySpamGauntlet({
honeypot: { body }, // omit to skip this layer
rateLimit: { ipHash, formName: "contact", maxPerHour: 5 }, // omit to skip this layer
turnstile: { token: body.token, secret: env.TURNSTILE_SECRET_KEY, remoteip: clientIp },
});
if (result.blocked) {
// result.reason: "honeypot" | "rate-limit" | "turnstile"
}
每层都是 选入 ——只通过您想要的检查的选项密钥;它们按蜜壶的顺序运行故障快活 —— 速率限制 —— Turnstile.
个人支票也出口(isHoneypotTriggered, (中文(简体) ), isRateLimited, (中文(简体) ), validateTurnstile, (中文(简体) ), HONEYPOT_FIELD如果你宁愿自己叫他们.
"你是一个机器人","我们不能问"是不同的答案(v0.3.0)
verifyTurnstile() 返回时 3个 结果,因为可能发生三件事:
import { verifyTurnstile } from "@broberg/forms-turnstile/server";
const r = await verifyTurnstile(token, secret, { remoteip, timeoutMs: 10_000 });
// { ok: true }
// { ok: false, reason: "rejected", errorCodes: [...] } ← Cloudflare said no
// { ok: false, reason: "unavailable", detail: "…" } ← we never got an answer
这个 从不扔 5xx、 HTML 错误页、 已丢弃的连接和
暂停 全部回来作为 unavailable 和可读的细节.
为什么会这样. 在v. 3. 0 之前 , 请检查url=值 (帮助), unavailable 联系到的呼叫者 两个
视云雀失败的形状而定不同的频道一个非JSON
机构 丢弃而JSON的尸体没有 success 返回字段 false。 。 。 .
没有一个呼叫者能够一致地处理它 false 树枝是有害的
一个 它变成了 reason: "turnstile",它使得 你辜负了机器人
检查"真实的人说她不是人类,"再试"是她唯一的
选择,她没有问题 日志上说 turnstile 同样,命名
错误的原因.
以出产为度量. 同样的缺陷 v. 2.0 固定在浏览器上 边 ——当客户端/服务器 双人有一个半个,先检查另一个半个.
没有判决不是判决.
选择政策
applySpamGauntlet 默认为 关闭失败数字 :
await applySpamGauntlet({ turnstile: { token, secret } });
// Cloudflare unreachable → THROWS. An unguarded route 500s. Nothing gets through.
这是蓄意的:它保留了这一一揽子方案已经为主导者所做的一切 所以升级从不会默默地把一个人的500元转换成400元 指责一个人类。 由你自己决定:
const r = await applySpamGauntlet({
turnstile: { token, secret, onUnavailable: "block" },
});
if (r.blocked && r.reason === "turnstile-unavailable") {
// Say what is true: "We can't check that right now — try shortly, or call us."
// NOT "you failed the bot check".
}
turnstile-unavailable 独立成员 SpamBlockReason,所以一个
switch 在编译时会告诉你 你有一个新案子
手柄。 这两个选项也都放在Hono中间软件上(timeoutMs, (中文(简体) ),
onUnavailable) (中文(简体) ).
现在暂停了 (违约10s). 没有了,一个被绞死的"云花" 连接将请求维持到平台停止引用为止——更糟糕的是 与本地相比,飞行/服务器无所适从,用户只看回旋器.
validateTurnstile() 已贬值但未作改变. 这是损失——它不能
分辨出两个失败, 并仍然把它们分成一个返回值和一个
掷出. 它的准确行为被测试所支撑 所以现有的呼叫者看不到任何变化
升级。 迁移到 verifyTurnstile 当你触摸呼叫站点.
费率限制提醒 : 仅限进程(a) Map——保护单机部署(飞来单机,一班机工人),但每个实例都有自己的计数器,所以它确实 没有 保护多层/无服务器。 对于共享的可插件存储限制器(Turso/Redis-backed),可访问范围为 @broberg/apikey是因为 SlidingWindowRateLimiter 换句话说.
测量您的实例数, 然后在常数旁边写 . 带 N 实例
maxPerHour × N没有maxPerHour所以说 数字在你的代码里是谎言 任何人阅读它下一个,除非评论说 所以说.flyctl scale show -a <app>用一行来回答 测量的fd- sundhed 2 机器对maxPerHour: 5并将实际限值记录为10 地点 常数而不是在承诺的信息, 这是正确的地方.这是重复的刹车 不是门 蜜壶和花心肠 保护; 如果利率限制是您所依赖的地层, 您需要一个 共享商店.
本地 dev / CI ——不需要真正的密钥
import { TURNSTILE_TEST_SITE_KEY, TURNSTILE_TEST_SECRET_KEY } from "@broberg/forms-turnstile/server";
云浮的官职 总是通过 测试密钥 – 安全可执行, 安全默认, 因此流的末端工作没有真正的 Turnstile 部件 .
不要用 E2E 装入 未解决 状态与测试键相对
他们解决了 几乎马上因此,支票喜欢 "提交按钮是 在用户解决挑战之前已禁用“ 正在比赛部件: 您正在尝试证明 存在在一秒钟.
fd-sundhed在收养时就打到这个了——同一说法在一页中传出 对方失败了, 不是因为应用程序行为不同 而是因为 种族主张。他们删除了支票,而不是增加一个等待,即 正确的呼唤:通过或失败的测试 都无法证明 向上转 这不是片面测试,而是状态测试 测试键不是 别动.
坚持坚持下去的各州——
solved解决后,failed与 联合国error当你屏蔽脚本。 注意这个陷阱 我们 已经建成, 总是通过键作为默认:方便和比赛 同样的特征.
运行时间现场钥匙交付
// GET /config route — serves the (public) site key at runtime so rotating it
// is a secret change, never a rebuild.
app.get("/config", (c) => c.json(getSitekeyResponse(env.TURNSTILE_SITE_KEY)));
部件悬钩- 反应( S)/react)或预告(/preact(中文(简体) )
懒惰地装入 Turnstile 脚本( cached + dedudeed) , 使部件一次成为 站点密钥可用. 两个适配器都是一样的 - 他们 钩子从哪一个包里取出不同,所以一个固定到达两个包.
import { useTurnstile } from "@broberg/forms-turnstile/react"; // or /preact
function ContactForm() {
const { widgetRef, token, status, error, reset } = useTurnstile(siteKey);
return (
<form onSubmit={onSubmit}>
{/* ...fields... */}
<div ref={widgetRef} data-testid="contact-form-captcha" />
<button type="submit" disabled={status !== "solved"}>Send</button>
{status === "failed" && <p role="alert">Spam-tjekket kunne ikke indlæses. {error}</p>}
</form>
);
}
siteKey 也许 null编号 :undefined 运行时 /config 获取在
飞行——读作: loading。 。 。 .
低键框——渲染选项(v. 4.0)
useTurnstile(siteKey, { appearance: "interaction-only", theme: "light", size: "flexible" });
未经更改改为 turnstile.render数字 : appearance (单位:千美元)"always" | "execute" | "interaction-only", (中文(简体) ), theme (单位:千美元)"light" | "dark" | "auto", (中文(简体) ), size (单位:千美元)"normal" | "flexible" | "compact", (中文(简体) ), language与 "interaction-only" 仅框
当 Cloudflare 想要点击时出现; 否则 status 继续直走
改为 solved 无所可见. 选项无法替换站点密钥或
钩回召相. 没有选项, 部件就和0. 3. 0 完全一样.
打开提交按钮 status,没有打开 token (v.2.0) (中文(简体) )
status | 含义 |
|---|---|
loading | 没有站点密钥, 或脚本仍在装入中 |
ready | 部件已打开等待用户 |
solved | token 有效 —— 这是您唯一需要提交的国家 |
failed | 没有干预,它就无法运作; error 说为什么 |
为什么这很重要. 在v.2.0之前,钩子只暴露在 token空时
符号有两个原因: 用户尚未解析,以及 这永远不会
工作。表单标注在 !token 因此显示一个从未提交过的按钮
启用了, 没有任何地方说为什么。 转弯被普通挡住
隐私扩展往往足以使这是普通用户的经验,而不是
边框.
在同一沉默中结束的三种不同的道路——这个脚本未能
装入,一个脚本 已装入 时 window.turnstile 从来没有出现过
widgetRef 从来没有被附着。 三个现在都结束了 failed 带有
独立 error由fd-sundhed提起,他发现三个人中的第一个由
读取焦油丸.
reset() 返回已解开的部件到 ready它会 没有 移动 a failed
部件退出 failed 重置一个从未装入的部件无法修复,
和洗钱 进入一个充满希望的状态 会抹去唯一的证据
真正的问题.
反应笔记
react 是一个可选的对等点( e)>=18) (中文(简体) ). 捆绑带 "use client",所以一个
Next.js App路由器项目可以在不包含客户端组件的情况下从客户端组件导入它
标记任何额外的东西——只有反应包携带; /server 财务报告和审定财务报表
/hono 保持服务器安全.
Hono 中间软件(@broberg/forms-turnstile/hono(中文(简体) )
读取JSON体本身(检查蜂蜜壶场+Turnstile sorder),运行起伏装置,并使用短路 400 在街区。 顺便说一句 解剖后的尸体被藏在上下文上 spamCheckedBody 因此,你的处理器不重读(已经消耗)请求流.
import { honoTurnstileMiddleware } from "@broberg/forms-turnstile/hono";
app.post(
"/api/contact",
honoTurnstileMiddleware({ secret: env.TURNSTILE_SECRET_KEY, formName: "contact", maxPerHour: 5 }),
(c) => {
const body = c.get("spamCheckedBody");
// ...persist + notify...
return c.json({ ok: true });
},
);