import { createRoute, type RouteConfig } from "@hono/zod-openapi"; import { Api, type ApiScope, apiResponse, type BrowserDownloadResult, type BrowserNavigation, type BrowserScreenshotView, type EndpointDef, type ManualAcquisitionResult, } from "@umt/contracts"; import type { Context } from "hono"; import type { z } from "zod"; import { type AuthEnv, requireScope } from "../../http/auth.ts"; import { ApiException } from "../../http/errors.ts"; import { InternalDispatcher } from "../search/dispatch.ts"; import type { ModuleApp } from "../types.ts"; import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts"; // C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用; // 输入类动作(导航/点击/输入/滚动/选择/标签页/触发下载)只接受持有人工租约的调用者(先接管)。 type Infer = T extends z.ZodType ? z.infer : Record; interface Input { params: Infer; query: Infer; body: Infer; c: Context; } const CODE = { not_found: "not_found", conflict: "conflict", precondition: "precondition_failed", bad_request: "bad_request", upstream: "upstream_unavailable", rate_limited: "rate_limited", } as const; function route(app: ModuleApp, def: D, handler: (input: Input) => Promise) { const request: RouteConfig["request"] = {}; if (def.params) request.params = def.params as never; if (def.query) request.query = def.query as never; if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true }; const r = createRoute({ method: def.method, path: def.path, summary: def.summary, tags: ["browser"], middleware: [requireScope(def.scope as ApiScope)] as never, request, responses: { 200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } }, }, }); // biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验 app.openapi(r, (async (c: any) => { const input = { params: def.params ? c.req.valid("param") : {}, query: def.query ? c.req.valid("query") : {}, body: def.body ? c.req.valid("json") : {}, c, } as Input; try { const data = def.response.parse(await handler(input)); return c.json({ ok: true, data }, 200); } catch (e) { if (e instanceof BrowserServiceError) throw new ApiException(CODE[e.code], e.message); throw e; } // biome-ignore lint/suspicious/noExplicitAny: 同上 }) as any); } /** 人工操作者:控制台会话 / API Key 的身份(租约 holder) */ function human(c: Context): Actor { const p = c.get("principal"); return { kind: "human", holder: `${p?.kind ?? "unknown"}:${p?.name ?? p?.id ?? "?"}` }; } const b64 = (data: Uint8Array) => Buffer.from(data).toString("base64"); export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService) { const dispatch = new InternalDispatcher(app); const B = Api.browser; route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") })); route(app, B.sessions, async ({ query }) => ({ items: await svc().list({ ...(query.siteId ? { siteId: query.siteId } : {}), includeClosed: query.includeClosed === "true", }), })); route(app, B.open, async ({ body }) => { const id = await svc().open({ siteId: body.siteId, ...(body.accountId !== undefined ? { accountId: body.accountId } : {}), ...(body.providerId ? { providerId: body.providerId } : {}), ...(body.url ? { url: body.url } : {}), }); return svc().view(id); }); route(app, B.get, async ({ params }) => svc().view(params.id)); route(app, B.close, async ({ params }) => { await svc().close(params.id, "closed_by_user"); return svc().view(params.id); }); route(app, B.openTab, async ({ params, body, c }) => { await svc().run(params.id, human(c), async (p, ref, x) => { if (body.url) await svc().limited(x.site); return p.openTab(ref.sessionId, { ...(body.url ? { url: body.url } : {}), budget: x.budget, as: "human" }); }); return svc().view(params.id); }); route(app, B.closeTab, async ({ params, c }) => { await svc().run(params.id, human(c), (p, ref) => p.closeTab(ref), { tabId: params.tabId }); return svc().view(params.id); }); route(app, B.navigate, async ({ params, body, c }): Promise => { if (!body.url && !body.action) throw new ApiException("bad_request", "url 与 action 二选一"); const out = await svc().run( params.id, human(c), async (p, ref, x) => { await svc().limited(x.site); const nav = body.url ? await p.navigate(ref, body.url, x.budget) : await p.history(ref, body.action!, x.budget); return { ...nav, loginRequired: p.sessionInfo(ref.sessionId)?.loginPage ?? false }; }, body.tabId ? { tabId: body.tabId } : {}, ); return { ...out, session: await svc().view(params.id) }; }); route(app, B.snapshot, async ({ params, query, c }) => svc().run( params.id, human(c), (p, ref) => p.snapshot(ref, query.offset !== undefined ? { offset: query.offset } : {}), { write: false, ...(query.tabId ? { tabId: query.tabId } : {}) }, ), ); route(app, B.screenshot, async ({ params, query, c }): Promise => { const shot = await svc().run(params.id, human(c), (p, ref) => p.screenshot(ref, { maxBytes: 8 * 1024 * 1024 }), { write: false, persist: false, ...(query.tabId ? { tabId: query.tabId } : {}), }); const view = await svc().view(params.id); return { mimeType: shot.mimeType, dataBase64: b64(shot.data), ...imageSize(shot.data), url: shot.url, modelSafe: shot.modelSafe, capturedAt: shot.capturedAt, controller: view.controller, }; }); const ok = async (id: string) => ({ ok: true as const, session: await svc().view(id) }); route(app, B.click, async ({ params, body, c }) => { await svc().run( params.id, human(c), (p, ref) => p.click(ref, body.target), body.tabId ? { tabId: body.tabId } : {}, ); return ok(params.id); }); route(app, B.type, async ({ params, body, c }) => { await svc().run( params.id, human(c), async (p, ref, x) => { if (body.key) return p.press(ref, body.key); if (!body.credential && body.text === undefined) throw new BrowserServiceError("bad_request", "text / credential / key 至少给一个"); const extra = { ...(body.submit !== undefined ? { submit: body.submit } : {}), ...(body.clear !== undefined ? { clear: body.clear } : {}), }; return p.type( ref, body.target ?? null, body.credential ? { kind: "secret", siteId: x.row.site_id, accountId: x.row.account_id, field: body.credential, ...extra } : { kind: "text", text: body.text ?? "", ...extra }, ); }, body.tabId ? { tabId: body.tabId } : {}, ); return ok(params.id); }); route(app, B.scroll, async ({ params, body, c }) => { await svc().run( params.id, human(c), (p, ref) => p.scroll(ref, { direction: body.direction, amount: body.amount }), body.tabId ? { tabId: body.tabId } : {}, ); return ok(params.id); }); route(app, B.select, async ({ params, body, c }) => { await svc().run( params.id, human(c), (p, ref) => p.select(ref, body.target, body.value), body.tabId ? { tabId: body.tabId } : {}, ); return ok(params.id); }); route(app, B.extract, async ({ params, body, c }) => ({ data: await svc().run(params.id, human(c), (p, ref) => p.extract(ref, body.schema), { write: false, ...(body.tabId ? { tabId: body.tabId } : {}), }), })); // 下载:给 target 视为人工输入(需接管);不给 target 只等待已触发的下载。 // 产物暂存核验后,给 submit 时转调 POST /acquisition-plans(同一鉴权、同一计划/去重/阈值/授权检查)。 route(app, B.download, async ({ params, body, c }): Promise => { const artifact = await svc().run( params.id, human(c), async (p, ref) => { if (body.target) await p.click(ref, body.target); return p.waitForDownload(ref, { timeoutMs: body.timeoutMs, maxBytes: 10 * 1024 * 1024 }); }, { write: !!body.target, ...(body.tabId ? { tabId: body.tabId } : {}) }, ); const v = await svc().verifyDownload(params.id, artifact); let submission: ManualAcquisitionResult | null = null; let note: string | null = null; if (v.duplicate) note = "同一会话已捕获过这个文件,未重复提交"; else if (!v.verification.valid) note = "不是有效的种子文件,未提交"; else if (!v.sourceReleaseId) note = "资源不在本地索引(先快照或搜索该站点),未提交"; else if (body.submit) { try { submission = await dispatch.call(c, "POST", "/acquisition-plans", { ...body.submit, sourceReleaseId: v.sourceReleaseId, }); if (!submission.submitted) note = "提交未生成下载计划,见 rejected"; } catch (e) { // 与 POST /acquisition-plans 相同的拒绝(未识别、无授权等):产物已暂存,结果照常返回 if (!(e instanceof ApiException) || e.code === "internal") throw e; note = `提交未通过:${e.message}`; } } await svc().recordDownload(params.id, { sha256: artifact.sha256, artifactId: artifact.id, sourceReleaseId: v.sourceReleaseId, planId: submission?.plan?.id ?? v.duplicate?.planId ?? null, }); return { artifact, verification: v.verification, sourceReleaseId: v.sourceReleaseId, duplicate: !!v.duplicate, submission, note, }; }); route(app, B.acquire, async ({ params, body, c }) => { const lease = await svc().acquire(params.id, human(c).holder, body.reason, body.ttlMs); return { lease, session: await svc().view(params.id) }; }); route(app, B.release, async ({ params, body }) => { const lease = await svc().release(params.id, { ...(body.leaseId ? { leaseId: body.leaseId } : {}), reason: body.reason, to: body.to, }); return { lease, session: await svc().view(params.id) }; }); }