feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
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<boolean> {
|
||||
const key = `rate:${tenantId}:${ip}`
|
||||
const last = await kv.getItem<number>(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<boolean> {
|
||||
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<void> {
|
||||
await kv.removeItem(`dedup:${tenantId}:${ip}`)
|
||||
}
|
||||
|
||||
/** 保存滑块验证码答案(一次性) */
|
||||
export async function saveCaptcha(token: string, answer: number): Promise<void> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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
|
||||
Reference in New Issue
Block a user