import { useStorage } from 'nitropack/runtime' /** * 留言提交防护:频率限制 + 未处理去重 + 滑块验证码票据。 * 基于 Nitro 内置 storage(默认内存驱动;生产多实例请换成 Redis 等共享驱动)。 * 所有 key 均带 tenantId,确保多租户互不影响。 */ const kv = useStorage('form-guard') const RATE_WINDOW = 60 * 1000 // 60s 内同一 IP+租户 仅可提交 1 次 const DEDUP_TTL = 7 * 24 * 60 * 60 * 1000 // 7 天内同一 IP+租户 仅允许 1 条未处理 const CAPTCHA_TTL = 5 * 60 * 1000 // 滑块验证码 5 分钟内有效 /** 频率限制:放行返回 true */ export async function rateLimitPass(tenantId: string, ip: string): Promise { const key = `rate:${tenantId}:${ip}` const last = await kv.getItem(key) if (last && Date.now() - last < RATE_WINDOW) return false await kv.setItem(key, Date.now()) return true } /** 未处理去重:允许提交返回 true */ export async function dedupPass(tenantId: string, ip: string): Promise { const key = `dedup:${tenantId}:${ip}` const raw = await kv.getItem<{ ts: number }>(key) if (raw && Date.now() - raw.ts < DEDUP_TTL) return false await kv.setItem(key, { ts: Date.now() }) return true } /** 上游受理成功后释放去重锁(或后台处理完成后调用),让该 IP 可再次提交 */ export async function clearDedup(tenantId: string, ip: string): Promise { await kv.removeItem(`dedup:${tenantId}:${ip}`) } /** 保存滑块验证码答案(一次性) */ export async function saveCaptcha(token: string, answer: number): Promise { await kv.setItem(`cap:${token}`, { answer, ts: Date.now() }) } /** 取出并消费滑块验证码票据;不存在/已用则返回 null */ export async function takeCaptcha(token: string): Promise<{ answer: number; ts: number } | null> { const v = await kv.getItem<{ answer: number; ts: number }>(`cap:${token}`) if (!v) return null await kv.removeItem(`cap:${token}`) return v } /** 仅查看滑块答案(不消费),用于校验 */ export async function peekCaptcha(token: string): Promise<{ answer: number; ts: number } | null> { return kv.getItem<{ answer: number; ts: number }>(`cap:${token}`) } const CAPTCHA_TOLERANCE = 8 // 拼图对齐容差(px),兼顾手指触控精度 /** * 校验滑块位置并消费 challenge;成功返回一次性提交票据,失败返回 null。 * @param token challenge token * @param x 用户拖动后拼图块中心 X 坐标 */ export async function verifyCaptcha(token: string, x: number): Promise { const v = await peekCaptcha(token) if (!v) return null if (Math.abs(x - v.answer) > CAPTCHA_TOLERANCE) return null await kv.removeItem(`cap:${token}`) const ticket = globalThis.crypto.randomUUID() await kv.setItem(`captcha-ticket:${ticket}`, { ts: Date.now() }) return ticket } /** 消费提交票据,成功返回 true(一次性) */ export async function checkCaptchaTicket(ticket: string): Promise { if (!ticket) return false const v = await kv.getItem(`captcha-ticket:${ticket}`) if (!v) return false await kv.removeItem(`captcha-ticket:${ticket}`) return true } export const CAPTCHA_MAX_AGE = CAPTCHA_TTL