// 公开检测入口 POST /api/detect:校验 → 限流 → 跑检测,应答是 NDJSON 流(application/x-ndjson): // {"type":"started"} // {"type":"heartbeat","elapsed_s":N} 每 5 秒一行,跑完为止(保持连接、给页面显示已用时间) // {"type":"done","id":..,"total_score":..,"summary":..} 报告已存进 KV REPORTS,页面跳 /r/ // {"type":"error","message":..} // 校验与限流的错在开流之前就回(400 / 413 / 429 / 503,JSON {error}),不进流。 // API key 只交给 runDetection,不写日志、不回显;报告里只有打码的。 import type { FetchLike } from "../client.js"; import type { DetectionReport } from "../models.js"; import { runDetection, type RunOptions } from "../runner.js"; import { copy, fill, type Lang } from "./i18n.js"; import { reserve, type LimitsKV, type LimitReason } from "./limits.js"; import { isPublicHost } from "./validate.js"; export interface ReportStore { put(key: string, value: string): Promise; } export interface DetectDeps { /** 测试注入假上游;不给用全局 fetch。 */ fetchImpl?: FetchLike; /** 心跳间隔,毫秒(默认 5000)。 */ heartbeatMs?: number; now?: () => number; /** 跑检测;测试可以换掉。 */ run?: (opts: RunOptions) => Promise; newId: () => string; /** Worker 的 ctx.waitUntil:开流之后后台那段要跑完。 */ waitUntil?: (p: Promise) => void; } export interface DetectRequest { base_url: string; api_key: string; model: string; mode: RunOptions["mode"]; } const MAX_BODY = 16 * 1024; /** * 把文本里出现的完整 key 换成打码的。有的中转站报错时把收到的 key 原样写进错误体,检测项会把错误体记进 details, * 不换掉就会进公开的报告。key 至少 8 个字符(parseRunRequest 管),不会误换。 */ export function scrubKey(text: string, key: string, masked: string): string { if (!key || key.length < 8) return text; return text.split(key).join(masked); } function jsonError(message: string, status: number, extra: Record = {}): Response { return new Response(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...extra }, }); } function limitMessage(lang: Lang, reason: LimitReason, retryAfterS: number): string { const e = copy(lang).errors; switch (reason) { case "ip10m": return fill(e.rateIp10m, { s: retryAfterS }); case "ipDay": return e.rateIpDay; case "host": return e.rateHost; case "inflight": return e.inflight; } } /** * 处理一个公开检测请求。parse 是 index.ts 的 parseRunRequest(同一套规则),这里再加「只许公网主机」与限流。 */ export async function handleDetect( request: Request, lang: Lang, limits: LimitsKV | undefined, reports: ReportStore, parse: (raw: unknown) => { base_url: string; api_key: string; model: string; mode: RunOptions["mode"] } | string, deps: DetectDeps, ): Promise { const e = copy(lang).errors; const len = Number(request.headers.get("content-length") ?? "0"); if (len > MAX_BODY) return jsonError(e.tooLarge, 413); let text: string; try { text = await request.text(); } catch { return jsonError(e.badJson, 400); } if (text.length > MAX_BODY) return jsonError(e.tooLarge, 413); let raw: unknown; try { raw = JSON.parse(text); } catch { return jsonError(e.badJson, 400); } // 公开入口不收并发、超时这两个参数:用默认值(并发 3、单请求 60 秒)。 if (raw && typeof raw === "object" && !Array.isArray(raw)) { const o = raw as Record; delete o["max_concurrent"]; delete o["timeout_s"]; } const req = parse(raw); if (typeof req === "string") return jsonError(req, 400); const url = new URL(req.base_url); if (!isPublicHost(url)) return jsonError(e.privateHost, 400); if (!limits) return jsonError(e.limiterDown, 503); const ip = request.headers.get("cf-connecting-ip") ?? "unknown"; const now = deps.now ?? Date.now; const decision = await reserve(limits, ip, url.hostname.toLowerCase(), now()); if (!decision.ok) { return jsonError(limitMessage(lang, decision.reason, decision.retryAfterS), 429, { "retry-after": String(decision.retryAfterS), }); } const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const enc = new TextEncoder(); // 心跳不等写完(调用方读得慢时不卡住);最后一行要等。调用方断开之后写会失败,忽略,检测照样跑完、照样存。 const send = (o: unknown) => writer.write(enc.encode(JSON.stringify(o) + "\n")).catch(() => {}); const run = deps.run ?? runDetection; const started = now(); const work = (async () => { void send({ type: "started" }); const hb = setInterval(() => void send({ type: "heartbeat", elapsed_s: Math.round((now() - started) / 1000) }), deps.heartbeatMs ?? 5000); try { const report = await run({ baseUrl: req.base_url, apiKey: req.api_key, model: req.model, mode: req.mode, timeoutS: 60, fetchImpl: deps.fetchImpl, }); const id = deps.newId(); await reports.put(id, scrubKey(JSON.stringify({ id, ...report }), req.api_key, report.api_key_masked)); clearInterval(hb); await send({ type: "done", id, total_score: report.total_score, summary: report.summary }); } catch (err) { clearInterval(hb); const msg = err instanceof Error ? err.message : String(err); await send({ type: "error", message: scrubKey(msg, req.api_key, "••••••") }); } finally { await decision.release().catch(() => {}); await writer.close().catch(() => {}); } })(); deps.waitUntil?.(work); return new Response(readable, { status: 200, headers: { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff", }, }); }