这个应用是纯静态页面 + 5 个单文件后端接口:题库、背书、模考、备份全部在浏览器里跑,学习数据只存在你自己的设备上。只有三件事需要服务器——可选的跨设备加密同步、AI 转发、以及纠错与报错收集。这 5 个接口的源码下面全文公开,你也可以自己搭一份。
| 路由 | 方法 | 作用 | 收到什么 | 它能不能看到你的内容 | 防护 |
|---|---|---|---|---|---|
/api/sync | GET · PUT | 跨设备同步那一条进度记录(可选功能) | 64 位指纹(记录名)+ base64 密文;PUT 空值=删除这条记录 | 看不到内容:不解析 data,也没有你的密码与派生密钥 | 来源校验 + 指纹格式校验 + 每 IP / 每指纹限流 + 单条 200KB 上限 |
/api/ai | POST | AI 讲解 / 模拟卷 / 助教问答的转发通道 | 你提问的题目与上下文(明文)、匿名设备标识、配额计数 | 能看到你发给 AI 的内容(它必须转发给模型服务商才能生成回答) | 同源校验 + 设备徽章(HMAC)+ 每设备每天 50 次 / 每 IP 每天 300 次 + 模型白名单 |
/api/ai-issue | GET | 给匿名设备签发一枚 HMAC 徽章(AI 配额的第二道门槛) | 匿名设备标识(前端随机生成,可随时清空) | 不涉及任何学习内容 | 同源校验 + 每 IP 每小时最多 20 枚 |
/api/feedback | POST · GET | 接收题库纠错/反馈;GET 是给站长看的只读看板(需口令) | 题目原文、题号、你写的说明、设备标识前 24 位、User-Agent(明文存 KV,180 天自动过期) | 能看到你提交的反馈文字(纠错本来就要人读) | 同源校验 + 每设备每小时 10 条 / 每 IP 每小时 100 条 + 单条 4KB |
/api/error | POST | 接收前端报错(白屏 / JS 异常 / 资源加载失败),用于修 bug | 错误信息、堆栈、页面路径、行号列号、User-Agent、设备标识前 24 位(明文存 KV,7 天过期) | 不涉及学习内容(除非报错文本里恰好带上,所以请不要把隐私写进批注/自出题) | 同源校验 + 每 IP 每分钟 30 条 / 每小时 120 条 |
/api/sync 只拿到密文(学不到任何内容);/api/ai、/api/feedback、/api/error 会看到你主动发出去的那部分文字——所以「不要把唯一副本交给云端、也不要把隐私写进提问/反馈」这两条建议是认真的。构建时直接从仓库里的 functions/api/*.js 读入,不是手抄的。你也可以在浏览器里直接访问 /api/sync?key=0000000000000000000000000000000000000000000000000000000000000000 看真实响应(会返回 {"empty":true})。
functions/api/sync.js(5185 字节)/* Cloudflare Pages Function:跨设备进度同步(端到端加密)。
安全加固(对应评测报告 D20/D21):
1) Origin 校验:带 Origin 的跨站请求一律 403;同源(含 *.pages.dev 预览域名)放行。
2) CORS 收紧:只在被允许的 Origin 上回 `Access-Control-Allow-Origin`,不再无条件 *。
3) 限流:每 IP / 每指纹各一套窗口计数(KV,默认 10 分钟窗口),超限 429 + Retry-After。
4) 指纹格式校验:只接受 64 位十六进制(客户端 SHA-256 指纹),其余一律 400。
5) 单条上限 200KB;写空值 = 删除该条。
- 服务器只收到密文(base64url)与不可逆指纹,没有密码、没有派生密钥,无法解密。
- 本文件不接触任何明文;加密与密钥派生全部发生在浏览器端(页面源码公开可查)。 */
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const KEY_RE = /^[0-9a-f]{64}$/; // 客户端指纹 = SHA-256 十六进制(32 字节)
const MAX_BYTES = 200000;
const WINDOW_S = 600; // 限流窗口:10 分钟
const LIMIT_IP = 150; // 每 IP 每窗口(读+写合计)
const LIMIT_READ = 150; // 每指纹每窗口读取
const LIMIT_WRITE = 60; // 每指纹每窗口写入
export async function onRequestOptions(context) {
const origin = context.request.headers.get("Origin");
if (!originAllowed(context.request)) return json({ error: "forbidden origin" }, 403);
return new Response(null, { status: 204, headers: corsHeaders(origin, false) });
}
export async function onRequestGet(context) {
const req = context.request;
if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
const key = (new URL(req.url).searchParams.get("key") || "").trim();
if (!KEY_RE.test(key)) return json({ error: "bad key" }, 400);
const rl = await rateLimit(context, req, key, "read", LIMIT_READ);
if (rl.blocked) return tooMany(rl, LIMIT_READ);
const val = await context.env.SYNC_KV.get(key);
if (val == null) return json({ empty: true }, 200, rl.headers);
return json({ data: val }, 200, rl.headers);
}
export async function onRequestPut(context) {
const req = context.request;
if (!originAllowed(req)) return json({ error: "forbidden origin" }, 403);
let j;
try { j = await req.json(); } catch (e) { return json({ error: "bad body" }, 400); }
const key = String(j.key || "").trim();
const data = String(j.data || "");
if (!KEY_RE.test(key)) return json({ error: "bad key" }, 400);
const rl = await rateLimit(context, req, key, "write", LIMIT_WRITE);
if (rl.blocked) return tooMany(rl, LIMIT_WRITE);
if (!data) {
await context.env.SYNC_KV.delete(key);
return json({ ok: true }, 200, rl.headers);
}
if (data.length > MAX_BYTES) return json({ error: "too large" }, 413, rl.headers);
await context.env.SYNC_KV.put(key, data);
return json({ ok: true }, 200, rl.headers);
}
/* ---------------- 工具 ---------------- */
function originAllowed(req) {
const origin = req.headers.get("Origin");
if (!origin) return true; // 服务端/命令行调用没有 Origin,不拦(真正的防线是限流)
try {
const o = new URL(origin);
const self = new URL(req.url);
if (o.host === self.host) return true; // 同源(含 <hash>.vetquiz.pages.dev 预览域名)
return ALLOW_ORIGINS.some((a) => origin === a || origin === a + "/");
} catch (e) { return false; }
}
function corsHeaders(origin, json) {
const h = { "Cache-Control": "no-store" };
if (json) h["Content-Type"] = "application/json";
h["X-Content-Type-Options"] = "nosniff";
if (origin && originAllowed({ url: "https://vetquiz.pages.dev/", headers: new Headers({ Origin: origin }) })) {
h["Access-Control-Allow-Origin"] = origin; // 只回允许的 Origin,不再 *
h["Vary"] = "Origin";
h["Access-Control-Allow-Methods"] = "GET,PUT,OPTIONS";
h["Access-Control-Allow-Headers"] = "Content-Type";
}
return h;
}
async function rateLimit(context, req, key, kind, limit) {
const kv = context.env.SYNC_KV;
const origin = req.headers.get("Origin");
const headers = corsHeaders(origin, false);
const bucket = Math.floor(Date.now() / (WINDOW_S * 1000));
if (!kv) return { blocked: false, headers };
const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
const ipUsed = await incr(kv, ["ip", ip, bucket]);
const keyUsed = await incr(kv, [kind, key.slice(0, 16), bucket]);
headers["x-ratelimit-limit"] = String(limit);
headers["x-ratelimit-used"] = String(kind === "read" ? keyUsed : keyUsed);
headers["x-ratelimit-ip-used"] = String(ipUsed);
const blocked = ipUsed > LIMIT_IP || (kind === "read" ? keyUsed > LIMIT_READ : keyUsed > LIMIT_WRITE);
return { blocked, headers, retry: secsToNextWindow() };
}
function tooMany(rl, limit) {
const h = Object.assign({}, rl.headers, { "Retry-After": String(rl.retry || WINDOW_S), "x-ratelimit-limit": String(limit) });
return json({ error: "too many requests", retry_after: rl.retry || WINDOW_S }, 429, h);
}
function json(obj, status, extra) {
const h = Object.assign({ "Content-Type": "application/json", "Cache-Control": "no-store" }, extra || {});
if (!h["X-Content-Type-Options"]) h["X-Content-Type-Options"] = "nosniff";
return new Response(JSON.stringify(obj), { status, headers: h });
}
async function incr(kv, parts) {
const k = ["sync:rl", ...parts].join(":");
const cur = Number(await kv.get(k)) || 0;
const nxt = cur + 1;
await kv.put(k, String(nxt), { expirationTtl: WINDOW_S * 3 });
return nxt;
}
function secsToNextWindow() {
return WINDOW_S - Math.floor((Date.now() / 1000) % WINDOW_S);
}
functions/api/ai.js(5920 字节)/* Cloudflare Pages Function:DeepSeek AI 代理(带访问门槛,防白嫖 + 防脚本刷费用)。
防滥用四件套:
1) Origin 校验:只接受本站同源请求,跨站脚本一律 403。
2) 匿名设备标识:前端生成 deviceId,调 /api/ai-issue 领取服务端 HMAC 签发的 badge;
本接口校验 badge 与 deviceId 匹配,无法本机伪造。
3) 每日配额:每个 deviceId 每天 50 次 + 每个 IP 每天上限(兜底防批量养号)→ KV 计数,超限 429。
4) 模型白名单:不信任客户端传的任意 model,只放行允许列表,其余强制回落默认,杜绝任意模型串。
- API Key 只存在环境变量 DEEPSEEK_API_KEY,前端永不接触。
- 流式:把 DeepSeek 的 SSE 响应原样转发。
新增环境变量:AI_BADGE_SECRET(HMAC 密钥,签发/校验设备 badge)。未配置时退化为「只查 deviceId + IP 上限」的开发宽松模式。
*/
export async function onRequestPost(context) {
const req = context.request;
let rate = null; // 每个请求各自一份,不能放在模块级(同一 isolate 并发会互相串用)
// 1) Origin 校验
const origin = req.headers.get("Origin");
if (!(context.env.AI_DEV === "1")) {
// 同源请求(浏览器 GET 不携带 Origin,同源 POST 带本站 Origin)应放行;只有带不匹配 Origin 的跨站请求才拦截
const okOrigin = !origin || ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/");
if (!okOrigin) return json({ error: "forbidden origin" }, 403);
}
// 2) 设备标识 + 签发校验
const deviceId = String(req.headers.get("X-Device-Id") || "").trim();
if (!DEVICE_RE.test(deviceId)) return json({ error: "missing or invalid X-Device-Id" }, 401);
if (context.env.AI_BADGE_SECRET) {
const badge = String(req.headers.get("X-Device-Badge") || "").trim();
if (!DEVICE_RE.test(badge)) return json({ error: "missing or invalid X-Device-Badge" }, 401);
const expect = await hmacHex(context.env.AI_BADGE_SECRET, deviceId);
if (!timingSafeEqualStr(expect, badge)) return json({ error: "badge mismatch" }, 401);
}
// 3) 每日配额(KV 计数)
const day = new Date().toISOString().slice(0, 10); // UTC 日
const cap = Number(context.env.AI_DEVICE_DAILY) || 50;
const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
const ipCap = Number(context.env.AI_IP_DAILY) || 300;
const kv = context.env.SYNC_KV;
if (kv) {
const used = await incr(kv, ["ai:d", deviceId, day]) ; // 每设备
const ipUsed = await incr(kv, ["ai:ip", ip, day]); // 每 IP
if (used > cap || ipUsed > ipCap) {
const limit = used > cap ? cap : ipCap;
return json({ error: { message: "今日内置 AI 次数已用完,明天 0 点自动恢复" } }, 429, {
"Retry-After": String(secsToMidnightUTC()), "x-ratelimit-limit": String(limit)
});
}
rate = { limit: cap, used: used, ipUsed: ipUsed };
}
let body;
try { body = await req.json(); } catch (e) { return json({ error: "body must be JSON" }, 400); }
if (!context.env.DEEPSEEK_API_KEY) return json({ error: "server not configured (DEEPSEEK_API_KEY)" }, 503);
const messages = Array.isArray(body.messages) ? body.messages : null;
if (!messages) return json({ error: "missing messages" }, 400);
// 4) 模型白名单:非法 model 统一回落默认,防止被当成任意 model 通道
const requested = typeof body.model === "string" ? body.model : "deepseek-chat";
const model = ALLOWED_MODELS.includes(requested) ? requested : DEFAULT_MODEL;
const stream = !!body.stream;
const payload = { model, messages, stream, temperature: typeof body.temperature === "number" ? body.temperature : 0.4 };
if (!stream) payload.max_tokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 1500;
// 需要严格 JSON(AI 模拟整卷)时,用 DeepSeek 的 json_object 模式,显著降低结构化输出跑偏
if (body.json_object === true && !stream) payload.response_format = { type: "json_object" };
let upstream;
try {
upstream = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": "Bearer " + context.env.DEEPSEEK_API_KEY },
body: JSON.stringify(payload)
});
} catch (e) { return json({ error: "upstream unreachable" }, 502); }
if (!upstream.ok) {
const t = await upstream.text().catch(() => "");
return new Response(t || upstream.statusText, { status: upstream.status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
}
const h = new Headers();
h.set("Content-Type", upstream.headers.get("Content-Type") || "application/json");
h.set("Cache-Control", "no-store");
if (rate) { h.set("x-ratelimit-limit", String(rate.limit)); h.set("x-ratelimit-usage", String(rate.used)); }
if (stream && upstream.body) {
return new Response(upstream.body, { status: 200, headers: h });
}
const text = await upstream.text();
return new Response(text, { status: 200, headers: h });
}
/* ---------------- 常量 & 工具 ---------------- */
const DEFAULT_MODEL = "deepseek-chat";
const ALLOWED_MODELS = ["deepseek-chat", "deepseek-reasoner"]; // DeepSeek 官方模型名,杜绝任意串
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;
function json(obj, status, extra) {
const h = { "Content-Type": "application/json", "Cache-Control": "no-store" };
if (extra) Object.assign(h, extra);
return new Response(JSON.stringify(obj), { status, headers: h });
}
function timingSafeEqualStr(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
async function hmacHex(secret, data) {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function incr(kv, parts) {
const key = ["ai:rl", ...parts].join(":");
const cur = Number(await kv.get(key)) || 0;
const nxt = cur + 1;
await kv.put(key, String(nxt), { expirationTtl: 26 * 3600 }); // 26h 自动过期,覆盖跨日清零
return nxt;
}
function secsToMidnightUTC() {
const now = Date.now();
const next = new Date(now);
next.setUTCDate(next.getUTCDate() + 1);
next.setUTCHours(0, 0, 0, 0);
return Math.ceil((next.getTime() - now) / 1000) + 60;
}functions/api/ai-issue.js(2099 字节)/* Cloudflare Pages Function:签发匿名设备 badge(并发量的准入门槛)。
前端首次使用时带上它生成的 deviceId 来这里领一枚 HMAC 签名徽章并缓存;
之后的 /api/ai 请求凭「deviceId + badge」被信任,配额按 deviceId 独立计。
- 本接口也不开大闸:同一个 IP 每小时最多签 20 枚,防止脚本批量养号刷配额。
- AI_BADGE_SECRET 未配置时直接返回空 badge(开发宽松模式,前端不缓存)。
*/
export async function onRequestGet(context) {
const req = context.request;
if (!(context.env.AI_DEV === "1")) {
const origin = req.headers.get("Origin");
// 同源 GET(浏览器不携带 Origin)应放行;只有带不匹配 Origin 的跨站请求才拦截
const okOrigin = !origin || ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/");
if (!okOrigin) return json({ error: "forbidden origin" }, 403);
}
const d = new URL(req.url).searchParams.get("d") || "";
if (!DEVICE_RE.test(d)) return json({ error: "bad device id" }, 400);
if (!context.env.AI_BADGE_SECRET) return json({ badge: "" }, 200);
// 每 IP 每小时 ≤20 枚
const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
const kv = context.env.SYNC_KV;
if (kv) {
const hour = new Date().toISOString().slice(0, 13);
const key = ["ai:issue", ip, hour].join(":");
const cur = Number(await kv.get(key)) || 0;
if (cur >= 20) return json({ error: { message: "领取太频繁,请稍后再试" } }, 429, { "Retry-After": "600" });
await kv.put(key, String(cur + 1), { expirationTtl: 2 * 3600 });
}
const badge = await hmacHex(context.env.AI_BADGE_SECRET, d);
return json({ badge }, 200);
}
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;
function json(obj, status, extra) {
const h = { "Content-Type": "application/json", "Cache-Control": "no-store" };
if (extra) Object.assign(h, extra);
return new Response(JSON.stringify(obj), { status, headers: h });
}
async function hmacHex(secret, data) {
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}functions/api/feedback.js(5441 字节)/* Cloudflare Pages Function:接收题库纠错/反馈,落 KV。
- 前端"⚠️ 这题有问题"弹层点「提交反馈」→ 本函数把 {题目, 题目ID, 说明, 时间} 写进 KV。
- 键:feedback:<UTC秒>:<短随机> → 值:JSON。后台用 SYNC_KV.list({ prefix:'feedback:' }) 批量导出即一份免费纠错工单。
- 反滥用:同一 IP 每小时 ≤100 条;单条 ≤4KB;禁止超大/超长。
- 产物是非敏感文本(用户自述 + 题目引用),不落任何个人信息,无需端到端加密。 */
export async function onRequestPost(context) {
const req = context.request;
// 同源限制:仅本应用域名可写(浏览器同源 POST 通常自然满足,这里做双保险)
const origin = req.headers.get("Origin") || "";
if (!(context.env.AI_DEV === "1") && origin && !ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/")) {
return json({ ok: false, error: "forbidden origin" }, 403);
}
let j;
try { j = await req.json(); } catch (e) { return json({ ok: false, error: "bad body" }, 400); }
const question = String(j.question || "").slice(0, 1200);
const questionId = String(j.questionId || "").slice(0, 60);
const note = String(j.note || "").slice(0, 1600);
if (!question && !note) return json({ ok: false, error: "empty" }, 400);
const kv = context.env.SYNC_KV;
if (kv) {
const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
const hour = new Date().toISOString().slice(0, 13);
// 设备级:同一匿名设备每小时 ≤10 条(X-Device-Id 由前端持久生成,与 AI 配额同源)
const deviceId = String(req.headers.get("X-Device-Id") || "").trim();
if (DEVICE_RE.test(deviceId)) {
const dKey = ["fb:rl:d", deviceId, hour].join(":");
const dCur = Number(await kv.get(dKey)) || 0;
if (dCur >= 10) return json({ ok: false, error: { message: "反馈提交太频繁,请稍后再试" } }, 429);
await kv.put(dKey, String(dCur + 1), { expirationTtl: 90 * 60 });
}
// IP 级兜底:同一 IP 每小时 ≤100 条,防脚本批量灌库
const rateKey = ["fb:rl", "ip", ip, hour].join(":");
const cur = Number(await kv.get(rateKey)) || 0;
if (cur >= 100) return json({ ok: false, error: { message: "提交太频繁,请稍后再试" } }, 429);
await kv.put(rateKey, String(cur + 1), { expirationTtl: 60 * 90 });
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const val = JSON.stringify({
t: new Date().toISOString(),
question, questionId, note,
d: deviceId ? deviceId.slice(0, 24) : "",
ua: String(req.headers.get("User-Agent") || "").slice(0, 160)
});
if (val.length <= 4096) await kv.put("feedback:" + id, val, { expirationTtl: 180 * 24 * 3600 });
}
return json({ ok: true }, 200);
}
/* 只读看板:GET /api/feedback?days=30&limit=50 (需带口令)
- 认证:?token=... 或 X-Feedback-Token 头或 Authorization: Bearer <token>,比对该站的 FB_VIEW_TOKEN。
- 只读:列出 + 按题/按日汇总 feedback:* 的 KV 记录,绝不写入。 */
export async function onRequestGet(context) {
const req = context.request;
const url = new URL(req.url);
const given =
String(url.searchParams.get("token") || "").trim() ||
String(req.headers.get("X-Feedback-Token") || "").trim() ||
String(req.headers.get("Authorization") || "").replace(/^Bearer\s+/i, "").trim();
const expected = String(context.env.FB_VIEW_TOKEN || "").trim();
if (!expected || given !== expected) return json({ ok: false, error: "forbidden" }, 403);
const kv = context.env.SYNC_KV;
if (!kv) return json({ ok: false, error: "no kv" }, 500);
const days = Math.min(720, Math.max(1, Number(url.searchParams.get("days")) || 30));
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || 50));
const keys = [];
let cursor;
do {
const page = await kv.list({ prefix: "feedback:", cursor });
keys.push(...(page.keys || []));
cursor = page.cursor;
} while (cursor && keys.length < 5000);
const entries = [];
for (const k of keys) {
let v; try { v = await kv.get(k.name); } catch (e) { continue; }
let o; try { o = JSON.parse(v); } catch (e) { o = {}; }
entries.push({ name: k.name, time: Date.parse(o.t || "") || 0, ...o });
}
entries.sort((a, b) => b.time - a.time);
const cutoff = Date.now() - days * 86400000;
const byDay = {}, byQ = {};
for (const e of entries) {
const d = (e.t || "").slice(0, 10); if (d) byDay[d] = (byDay[d] || 0) + 1;
const qid = e.questionId || "(无题号)";
const g = (byQ[qid] = byQ[qid] || { count: 0, last: 0, notes: [] });
g.count++; if (e.time) g.last = Math.max(g.last, e.time);
if (e.note) g.notes.push(String(e.note));
}
const topByQuestion = Object.keys(byQ)
.map(function (qid) {
const g = byQ[qid];
return { questionId: qid, count: g.count, last: g.last, samples: g.notes.slice(-3).map(function (s) { return s.slice(0, 120); }) };
})
.sort((a, b) => b.count - a.count).slice(0, 30);
return json({
ok: true,
viewToken: expected.slice(0, 3) + "…",
total: entries.length,
inWindow: entries.filter(function (e) { return e.time >= cutoff; }).length,
days, to: new Date().toISOString(), from: new Date(cutoff).toISOString(),
byDay: Object.keys(byDay).sort().map(function (d) { return { day: d, count: byDay[d] }; }),
topByQuestion,
recent: entries.slice(0, limit).map(function (e) {
return { t: e.t, questionId: e.questionId, question: String(e.question || "").slice(0, 80), note: String(e.note || "").slice(0, 160), device: e.d };
})
}, 200);
}
function json(obj, status) {
return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
}
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
const DEVICE_RE = /^[A-Za-z0-9_-]{8,64}$/;functions/api/error.js(2426 字节)/* Cloudflare Pages Function:前端错误上报(白屏/JS 异常/资源加载失败)。
- 前端 window.onerror + unhandledrejection 收拢后 POST 到本接口。
- 反滥用:同 IP 每分钟 ≤30 条、每小时 ≤120 条;字段截断。
- 落 KV 键 error:<ISO>:<rand>,后台 list({prefix:'error:'}) 即可读。 */
export async function onRequestPost(context) {
const req = context.request;
const origin = req.headers.get("Origin") || "";
if (!(context.env.AI_DEV === "1") && origin && !ALLOW_ORIGINS.some((o) => origin === o || origin === o + "/")) {
return json({ ok: false, error: "forbidden origin" }, 403);
}
let j;
try { j = await req.json(); } catch (e) { return json({ ok: false, error: "bad body" }, 400); }
const msg = String(j.msg || "").slice(0, 600);
const stack = String(j.stack || "").slice(0, 1200);
const url = String(j.url || "").slice(0, 300);
const line = Number(j.line) || 0;
const col = Number(j.col) || 0;
if (!msg && !stack) return json({ ok: true }, 200); // 空错误按成功吞掉(避免脏数据)
const kv = context.env.SYNC_KV;
if (kv) {
const ip = (req.headers.get("CF-Connecting-IP") || "unknown").replace(/[^\w.:]/g, "_");
const now = Date.now();
const minK = ["er:rl:m", ip, Math.floor(now / 60000)].join(":");
const hrK = ["er:rl:h", ip, new Date(now).toISOString().slice(0, 13)].join(":");
const m = Number(await kv.get(minK)) || 0;
const h = Number(await kv.get(hrK)) || 0;
if (m >= 30 || h >= 120) return json({ ok: true, error: "rate limited" }, 429);
await kv.put(minK, String(m + 1), { expirationTtl: 90 });
await kv.put(hrK, String(h + 1), { expirationTtl: 3600 });
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const deviceId = String(req.headers.get("X-Device-Id") || "").slice(0, 24);
const val = JSON.stringify({
t: new Date().toISOString(), msg, stack, url, line, col,
d: deviceId, p: locationHint(context.request, url),
ua: String(req.headers.get("User-Agent") || "").slice(0, 120)
});
if (val.length <= 4096) await kv.put("error:" + id, val, { expirationTtl: 7 * 24 * 3600 });
}
return json({ ok: true }, 200);
}
function locationHint(req, url) {
try { return new URL(url || req.url).pathname.slice(0, 80); } catch (e) { return ""; }
}
function json(obj, status) {
return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
}
const ALLOW_ORIGINS = ["https://vetquiz.pages.dev"];
/api/ai,再由它转发给模型服务商 DeepSeek(默认模型 deepseek-chat,可在白名单内选择 deepseek-reasoner)。密钥只存在后端环境变量里,前端拿不到,也不会把你的 Key 发给任何第三方。online/。SYNC_KV(5 个接口共用它做存取与限流计数)。DEEPSEEK_API_KEY(要 AI 功能才需要)、AI_BADGE_SECRET、AI_DEVICE_DAILY、AI_IP_DAILY、FB_VIEW_TOKEN;把各文件里的 ALLOW_ORIGINS 改成你自己的域名。npx wrangler pages deploy .(本项目的 wrangler.toml 里已声明 pages_build_output_dir 与 KV 绑定)。DEEPSEEK_API_KEY 请用 Cloudflare 的 Secret(wrangler pages secret put)而不是明文变量,避免 Key 出现在配置与面板里;② 只保留你真正要用的接口,不用 AI 就把 ai.js / ai-issue.js 删掉。[vars] 里(部署管线限制,见仓库注释),这一点也一并说明,不藏着。PBKDF2-SHA256(15 万次迭代、固定盐)→ ① AES-256-GCM 加密钥(只在内存里);② SHA-256(盐 :: 密码) 作为记录名(指纹,不可逆)。crypto.getRandomValues 生成,只显示给你、只以密文形式参与包裹。kvKeys / envSeal / kvEncrypt;③ Network 里看 PUT /api/sync 的请求体只有指纹与 base64 密文;④ 直接读上面的后端源码。{ __app, data:{...各存储键...} };加密导出则是 { __app, __enc:1|2, ... }。