feat(browser): unify site credentials with browser profiles (BR-2)
- Inject the site account Cookie (manual + auto-login session, session wins)
into the camofox profile before the first tab opens; password accounts
without a session run refresh-login first; API Key sites are skipped.
- Cookie expired after injection -> credential.status=expired; cookie
accounts go to waiting/login_required ("log in once, applies site-wide").
- Write back site-domain cookies from the browser on handover, session close,
every 10 min of activity, and from refresh-login when the stored cookie is
invalid (verified before saving). user_info.cookieOrigin records
source=browser; source shown as manual/login/browser in the origin view.
- New camofox plugin umt-cookies (GET/POST /umt/sessions/:userId/cookies
under the access key); profile dir 0700/files 0600 in the desktop image.
- Single entry: sources-identity/service/credentials.ts.
- Real check: HHClub cookie injected into a fresh profile -> home page
logged in (logout link present), 5 cookies read back.
Requires rebuilding umt-browser-desktop (docker compose build camofox &&
docker compose up -d camofox) to take effect.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
901b01d1a1
commit
28974d1e1a
58 files changed
+2843
-1112
No files matched your search
@@ -0,0 +1,203 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { readAccountCookie } from "../../sources-identity/service/credentials.ts";
|
||||
import { type BrowserHarness, createBrowserHarness, type Loose, ORIGIN, pgAvailable } from "./harness.ts";
|
||||
|
||||
// BR-2 凭据统一(对 testkit mock camofox + mock NexusPHP 登录态判定):
|
||||
// 打开会话注入站点账号 Cookie → 首页 / 受保护页是登录态;Cookie 过期 → 需要人工登录;
|
||||
// 人工在画面里登录后交还 → 读回站点域名 Cookie 写进账号(来源 browser);关闭时也回写;没有插件时降级说明。
|
||||
|
||||
const up = await pgAvailable();
|
||||
const GOOD = "good-session-value-7f3a";
|
||||
const STALE = "stale-session-value-11";
|
||||
const FRESH = "fresh-browser-login-9c2";
|
||||
|
||||
describe.skipIf(!up)("BR-2 站点账号 Cookie ⇄ 浏览器 profile", () => {
|
||||
let h: BrowserHarness;
|
||||
let cookieAcc: string;
|
||||
|
||||
const setCookieAccount = async (cookie: string) => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: cookie });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref, session_secret_ref: null, status: "active", user_info: "{}" })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
};
|
||||
const open = async (path = "/torrents.php") =>
|
||||
(await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: cookieAcc, url: `${ORIGIN}${path}` } }))
|
||||
.body.data as Loose;
|
||||
const closeAll = async () => {
|
||||
for (const s of (await h.req("/browser/sessions")).body.data.items as Loose[])
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
});
|
||||
cookieAcc = h.accountId;
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入:Cookie 账号打开会话前写进该 profile,受保护页直接是登录态;响应里没有 Cookie 值", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("automation");
|
||||
expect(s.currentUrl).toBe(`${ORIGIN}/torrents.php`);
|
||||
expect(s.credential).toMatchObject({ status: "injected", source: "manual", count: 2 });
|
||||
expect(JSON.stringify(s)).not.toContain(GOOD);
|
||||
// 注入走 umt-cookies 插件,早于打开标签页;Cookie 按站点主机 host-only、https → secure
|
||||
const calls = h.mock.calls.map((c) => `${c.method} ${c.path}`);
|
||||
const inj = calls.indexOf(`POST /umt/sessions/${encodeURIComponent(s.profileKey)}/cookies`);
|
||||
expect(inj).toBeGreaterThanOrEqual(0);
|
||||
expect(inj).toBeLessThan(calls.lastIndexOf("POST /tabs"));
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
expect(jar.find((c) => c.name === "c_secure_pass")).toMatchObject({
|
||||
value: GOOD,
|
||||
domain: "audiences.example",
|
||||
secure: true,
|
||||
});
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("过期:注入后仍落在登录页 → 需要人工登录;人工登录后交还 → 回写账号 Cookie(来源浏览器),下次抓取用新值", async () => {
|
||||
h.mock.loggedIn.clear();
|
||||
h.mock.cookieJars.clear();
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${STALE}`);
|
||||
const s = await open();
|
||||
expect(s.controller).toBe("waiting");
|
||||
expect(s.waitingReason).toBe("login_required");
|
||||
expect(s.credential).toMatchObject({ status: "expired" });
|
||||
expect(s.credential.message).toContain("登录一次");
|
||||
const d = await h.db.selectFrom("decision").selectAll().where("subject_id", "=", s.id).executeTakeFirstOrThrow();
|
||||
expect(d.summary).toContain("交还后全站生效");
|
||||
|
||||
// 人工接管,在画面里登录(桌面模式里的导航不经 API:直接改 mock 的登录态与当前地址)
|
||||
expect((await h.req(`/browser/sessions/${s.id}/control/acquire`, { body: { reason: "登录" } })).status).toBe(200);
|
||||
const tab = [...h.mock.tabs.values()].find((t) => t.userId === s.profileKey)!;
|
||||
h.mock.loggedIn.add(s.profileKey);
|
||||
h.mock.cookieJars.get(s.profileKey)!.push(
|
||||
{
|
||||
name: "c_secure_pass",
|
||||
value: FRESH,
|
||||
domain: "audiences.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
},
|
||||
// 别的域名的 Cookie 不回写
|
||||
{
|
||||
name: "_ga",
|
||||
value: "tracker",
|
||||
domain: ".analytics.example",
|
||||
path: "/",
|
||||
expires: -1,
|
||||
httpOnly: false,
|
||||
secure: true,
|
||||
},
|
||||
);
|
||||
tab.url = `${ORIGIN}/torrents.php`;
|
||||
const r = await h.req(`/browser/sessions/${s.id}/control/release`, { body: {} });
|
||||
expect(r.status).toBe(200);
|
||||
|
||||
const acc = await readAccountCookie(h.ctx, cookieAcc);
|
||||
expect(acc).toMatchObject({ source: "browser", applicable: true });
|
||||
expect(acc!.header).toContain(`c_secure_pass=${FRESH}`);
|
||||
expect(acc!.header).toContain("c_secure_uid=1001");
|
||||
expect(acc!.header).not.toContain("_ga");
|
||||
const row = await h.db.selectFrom("site_account").selectAll().where("id", "=", cookieAcc).executeTakeFirstOrThrow();
|
||||
expect((row.user_info as Loose).cookieOrigin).toMatchObject({ source: "browser", by: "browser" });
|
||||
const view = (await h.req(`/browser/sessions/${s.id}`)).body.data;
|
||||
expect(view.credential).toMatchObject({ status: "injected", source: "browser" });
|
||||
expect(view.credential.syncedAt).toBeTruthy();
|
||||
expect(JSON.stringify(view)).not.toContain(FRESH);
|
||||
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("关闭会话时回写;停在登录页不回写(未登录的 Cookie 不覆盖存储)", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
expect(s.credential.status).toBe("injected");
|
||||
// 站点在浏览器里轮换了会话 Cookie
|
||||
const jar = h.mock.cookieJars.get(s.profileKey)!;
|
||||
jar.find((c) => c.name === "c_secure_pass")!.value = "rotated-by-site-44";
|
||||
await h.req(`/browser/sessions/${s.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain("c_secure_pass=rotated-by-site-44");
|
||||
|
||||
// 登录页:不回写
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
const s2 = await open("/login.php");
|
||||
h.mock.cookieJars.get(s2.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "anonymous-junk";
|
||||
await h.req(`/browser/sessions/${s2.id}`, { method: "DELETE" });
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
});
|
||||
|
||||
it("有活动时每 10 分钟读回一次", async () => {
|
||||
await setCookieAccount(`c_secure_uid=1001; c_secure_pass=${GOOD}`);
|
||||
h.mock.cookieJars.clear();
|
||||
const s = await open();
|
||||
h.mock.cookieJars.get(s.profileKey)!.find((c) => c.name === "c_secure_pass")!.value = "periodic-rotated-5";
|
||||
// 刚打开:不读回
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
expect((await readAccountCookie(h.ctx, cookieAcc))!.header).toContain(`c_secure_pass=${GOOD}`);
|
||||
// 会话创建时间拨到 11 分钟前:下一次活动触发后台读回
|
||||
await h.db
|
||||
.updateTable("browser_session")
|
||||
.set({ created_at: new Date(Date.now() - 11 * 60_000) })
|
||||
.where("id", "=", s.id)
|
||||
.execute();
|
||||
await h.req(`/browser/sessions/${s.id}/snapshot`);
|
||||
await expect
|
||||
.poll(async () => (await readAccountCookie(h.ctx, cookieAcc))!.header, { timeout: 3000 })
|
||||
.toContain("c_secure_pass=periodic-rotated-5");
|
||||
await closeAll();
|
||||
});
|
||||
|
||||
it("匿名会话与 API Key 账号不注入", async () => {
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, accountId: null, url: `${ORIGIN}/` } });
|
||||
expect(r.body.data.credential).toMatchObject({ status: "not_applicable" });
|
||||
await closeAll();
|
||||
const ref = await h.secrets.create({ purpose: "site_api_key", plaintext: "api-key-xyz" });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "api_key", secret_ref: ref })
|
||||
.where("id", "=", cookieAcc)
|
||||
.execute();
|
||||
const before = h.mock.callsTo(/\/cookies$/).length;
|
||||
const s = await open("/");
|
||||
expect(s.credential).toMatchObject({ status: "not_applicable" });
|
||||
expect(h.mock.callsTo(/\/cookies$/).length).toBe(before);
|
||||
await closeAll();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("BR-2 浏览器服务没有 umt-cookies 插件", () => {
|
||||
let h: BrowserHarness;
|
||||
beforeAll(async () => {
|
||||
h = await createBrowserHarness("cred_noplugin", {
|
||||
sessionCookie: { name: "c_secure_pass", valid: [GOOD], fresh: FRESH },
|
||||
cookieApi: "none",
|
||||
});
|
||||
}, 120_000);
|
||||
afterAll(async () => {
|
||||
await h?.close();
|
||||
});
|
||||
|
||||
it("注入报不支持,会话照常打开并说明原因", async () => {
|
||||
const ref = await h.secrets.create({ purpose: "site_cookie", plaintext: `c_secure_pass=${GOOD}` });
|
||||
await h.db
|
||||
.updateTable("site_account")
|
||||
.set({ auth_kind: "cookie", secret_ref: ref })
|
||||
.where("id", "=", h.accountId)
|
||||
.execute();
|
||||
const r = await h.req("/browser/sessions", { body: { siteId: h.siteId, url: `${ORIGIN}/` } });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.credential).toMatchObject({ status: "unsupported", count: 0 });
|
||||
expect(r.body.data.credential.message).toContain("umt-cookies");
|
||||
});
|
||||
});
|
||||
@@ -53,11 +53,23 @@ export interface BrowserHarness {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createBrowserHarness(name: string, opts: { captcha?: boolean } = {}): Promise<BrowserHarness> {
|
||||
export async function createBrowserHarness(
|
||||
name: string,
|
||||
opts: {
|
||||
captcha?: boolean;
|
||||
/** BR-2:mock 站点按这个 Cookie 判定登录态 */
|
||||
sessionCookie?: { name: string; valid: string[]; fresh: string };
|
||||
cookieApi?: "plugin" | "upstream" | "none";
|
||||
cookieImportKey?: string;
|
||||
} = {},
|
||||
): Promise<BrowserHarness> {
|
||||
const torrent = buildFakeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.x265-GRP" });
|
||||
const mock = await createMockCamofox({
|
||||
accessKey: ACCESS_KEY,
|
||||
...(opts.cookieApi ? { cookieApi: opts.cookieApi } : {}),
|
||||
...(opts.cookieImportKey ? { cookieImportKey: opts.cookieImportKey } : {}),
|
||||
site: {
|
||||
...(opts.sessionCookie ? { sessionCookie: opts.sessionCookie } : {}),
|
||||
origin: ORIGIN,
|
||||
username: "fixture-user",
|
||||
password: PASSWORD,
|
||||
|
||||
@@ -40,7 +40,10 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu
|
||||
registerRoutes(app, ctx) {
|
||||
registerBrowserRoutes(app, () => service(ctx));
|
||||
},
|
||||
registerTasks() {},
|
||||
registerTasks(_registry, ctx) {
|
||||
// worker 里的 refresh-login 也要能从浏览器 profile 读回 Cookie(BR-2):建服务即注册读回钩子
|
||||
service(ctx);
|
||||
},
|
||||
registerCapabilities(registry, ctx) {
|
||||
service(ctx);
|
||||
registerBrowserCapabilities(registry, () => service(ctx));
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
type BrowserProvider,
|
||||
type BrowserSecretResolver,
|
||||
CAMOFOX_VERIFIED_RELEASES,
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
type DownloadSink,
|
||||
type ElementTarget,
|
||||
getSiteDefinition,
|
||||
headerFromCookies,
|
||||
hostAllowed,
|
||||
imageDimensions,
|
||||
type PageMetrics,
|
||||
@@ -22,7 +25,9 @@ import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserController,
|
||||
BrowserCookie,
|
||||
BrowserCoordinateSpace,
|
||||
BrowserCredentialState,
|
||||
BrowserDownloadVerification,
|
||||
BrowserEvidenceRef,
|
||||
BrowserHealth,
|
||||
@@ -42,6 +47,12 @@ import {
|
||||
sql,
|
||||
} from "@umt/db";
|
||||
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||
import { refreshSiteAccount } from "../sources-identity/service/account.ts";
|
||||
import {
|
||||
readAccountCookie,
|
||||
registerBrowserCookieReader,
|
||||
writeBrowserCookie,
|
||||
} from "../sources-identity/service/credentials.ts";
|
||||
import type { ServerContext } from "../types.ts";
|
||||
|
||||
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
|
||||
@@ -134,8 +145,13 @@ export interface RecoveryState {
|
||||
waitingDecisionId?: string | null;
|
||||
releasedAt?: string;
|
||||
downloads?: { sha256: string; artifactId: string; sourceReleaseId: string | null; planId: string | null }[];
|
||||
/** BR-2:打开时的凭据注入结果与最近一次回写检查 */
|
||||
credential?: BrowserCredentialState;
|
||||
}
|
||||
|
||||
/** 有活动的会话每隔多久从浏览器读回一次 Cookie */
|
||||
export const COOKIE_SYNC_INTERVAL_MS = 10 * 60_000;
|
||||
|
||||
/** 环境变量(BROWSER_PROVIDER_URL)登记的浏览器服务 */
|
||||
export const ENV_PROVIDER_NAME = "camofox-env";
|
||||
const ENV_MANAGED = "env";
|
||||
@@ -150,6 +166,8 @@ export class BrowserService {
|
||||
readonly opts: BrowserModuleOptions = {},
|
||||
) {
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回一次(sources-identity 经钩子调用,不直接依赖本模块)
|
||||
registerBrowserCookieReader(ctx, (siteId, accountId) => this.readProfileCookie(siteId, accountId));
|
||||
}
|
||||
|
||||
// ---------- 浏览器服务 ----------
|
||||
@@ -622,6 +640,7 @@ export class BrowserService {
|
||||
lease: lease ? toLease(lease) : null,
|
||||
needsSnapshot: rs.needsSnapshot === true,
|
||||
waitingReason: row.controller === "waiting" || row.controller === "human" ? (rs.waitingReason ?? null) : null,
|
||||
credential: rs.credential ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -680,6 +699,7 @@ export class BrowserService {
|
||||
// 数据库里已没有未关闭的会话:本进程内存里同 profile 还开着的是别的进程关掉的,标关闭,否则适配器拒绝新开
|
||||
for (const m of await provider.listSessions())
|
||||
if (m.profileKey === profileKey && m.controller !== "closed") provider.setController(m.id, "closed");
|
||||
const prep = await this.prepareCookies(site, accountId);
|
||||
await this.rateLimit(site);
|
||||
let res: Awaited<ReturnType<BrowserProvider["openSession"]>>;
|
||||
try {
|
||||
@@ -690,11 +710,28 @@ export class BrowserService {
|
||||
profileKey,
|
||||
initialUrl: req.url ?? site.base_url,
|
||||
budget,
|
||||
...(prep.cookies.length ? { cookies: prep.cookies } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
throw fromBrowserError(e);
|
||||
}
|
||||
const s = res.session;
|
||||
const credential: BrowserCredentialState = { ...prep.state };
|
||||
if (res.cookies) {
|
||||
credential.status = res.cookies.status;
|
||||
credential.count = res.cookies.count;
|
||||
if (res.cookies.status === "unsupported")
|
||||
credential.message = "浏览器服务没有 Cookie 接口(镜像未带 umt-cookies 插件),没有注入账号登录态";
|
||||
else if (res.cookies.status === "failed") credential.message = "注入账号 Cookie 失败";
|
||||
}
|
||||
// 注入了 Cookie 仍落在登录页:Cookie 过期。Cookie 账号(没有密码可自动登录)转"需要人工登录":在画面里登录一次,
|
||||
// 交还 / 关闭时回写,全站生效;账密账号留给 Agent 用凭证注入登录(browser.interact credential),只记状态
|
||||
const loginPage = provider.sessionInfo(s.id)?.loginPage === true;
|
||||
const needHuman = credential.status === "injected" && loginPage && prep.authKind === "cookie";
|
||||
if (credential.status === "injected" && loginPage) {
|
||||
credential.status = "expired";
|
||||
credential.message = "账号登录态已失效:接管后在画面里登录一次,交还后 HTTP 抓取、RSS、快照都用新的登录态";
|
||||
}
|
||||
await this.ctx.db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.insertInto("browser_session")
|
||||
@@ -708,7 +745,7 @@ export class BrowserService {
|
||||
current_url: s.currentUrl,
|
||||
last_activity_at: new Date(s.lastActivityAt),
|
||||
upstream_ref: JSON.stringify(s.upstreamRef),
|
||||
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true }),
|
||||
recovery_state: JSON.stringify({ currentTabId: res.tab.upstreamTabId, needsSnapshot: true, credential }),
|
||||
})
|
||||
.execute();
|
||||
await trx
|
||||
@@ -722,11 +759,160 @@ export class BrowserService {
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
if (needHuman) await this.enterWaiting(await this.sessionRow(s.id), "login_required");
|
||||
return s.id;
|
||||
}
|
||||
|
||||
// ---------- 凭据统一(BR-2) ----------
|
||||
|
||||
/**
|
||||
* 打开会话前准备要注入的 Cookie:匿名会话与 API Key 类站点(馒头:网页登录态与 API Key 无关)不注入;
|
||||
* 账密账号还没有会话 Cookie 时先走一次 refresh-login(与站点中心"刷新登录"同一流程)再取。
|
||||
*/
|
||||
private async prepareCookies(
|
||||
site: SiteRow,
|
||||
accountId: string | null,
|
||||
): Promise<{ cookies: BrowserCookie[]; state: BrowserCredentialState; authKind: string | null }> {
|
||||
const state = (
|
||||
status: BrowserCredentialState["status"],
|
||||
message: string | null,
|
||||
extra: Partial<BrowserCredentialState> = {},
|
||||
): BrowserCredentialState => ({
|
||||
status,
|
||||
source: null,
|
||||
sourceAt: null,
|
||||
count: 0,
|
||||
message,
|
||||
syncedAt: null,
|
||||
...extra,
|
||||
});
|
||||
if (!accountId)
|
||||
return { cookies: [], authKind: null, state: state("not_applicable", "匿名会话,不注入账号登录态") };
|
||||
const kind = (getSiteDefinition(site.key)?.umt as { kind?: string } | undefined)?.kind;
|
||||
let acc = await readAccountCookie(this.ctx, accountId);
|
||||
if (!acc?.applicable || kind === "mteam")
|
||||
return {
|
||||
cookies: [],
|
||||
authKind: acc?.authKind ?? null,
|
||||
state: state("not_applicable", "API Key 类站点 / 账号:网页登录态与 API Key 无关,不注入;需要时在桌面里登录"),
|
||||
};
|
||||
const rt = this.opts.runtime?.(this.ctx);
|
||||
if (!acc.header && acc.authKind === "password" && rt) {
|
||||
await refreshSiteAccount(rt, accountId, { mode: "login" }).catch(() => null);
|
||||
acc = (await readAccountCookie(this.ctx, accountId)) ?? acc;
|
||||
}
|
||||
if (!acc.header)
|
||||
return { cookies: [], authKind: acc.authKind, state: state("none", "账号还没有可用的登录态(Cookie)") };
|
||||
const cookies = cookiesFromHeader(acc.header, site.base_url, Math.floor(this.now().getTime() / 1000));
|
||||
return {
|
||||
cookies,
|
||||
authKind: acc.authKind,
|
||||
state: state("injected", null, {
|
||||
source: acc.source,
|
||||
sourceAt: acc.sourceAt ? acc.sourceAt.toISOString() : null,
|
||||
count: cookies.length,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从该站点 / 账号的浏览器 profile 读回站点域名的 Cookie 头串(没有启用的浏览器服务或插件时 null) */
|
||||
async readProfileCookie(siteId: string, accountId: string): Promise<string | null> {
|
||||
const prow = await this.providerRow().catch(() => null);
|
||||
if (!prow) return null;
|
||||
const provider = this.providerFor(prow);
|
||||
if (!provider.getCookies) return null;
|
||||
const site = await this.liveSite(siteId);
|
||||
const list = await provider.getCookies(profileKeyFor(siteId, accountId), [site.base_url]);
|
||||
return headerFromCookies(list, site.base_url, Math.floor(this.now().getTime() / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* 回写:从会话的浏览器 profile 读回站点域名的 Cookie,与凭据存储比较,有变化则更新(来源 browser)。
|
||||
* 当前页面是登录页时不回写(未登录的 Cookie 不能覆盖可能仍有效的登录态)。失败只记在会话状态里,不抛出。
|
||||
*/
|
||||
async syncCookies(id: string, why: "release" | "close" | "periodic"): Promise<{ changed: boolean } | null> {
|
||||
try {
|
||||
const row = await this.sessionRow(id);
|
||||
if (!row.account_id) return null;
|
||||
const provider = this.providers.get(row.provider_id)?.provider;
|
||||
if (!provider?.getCookies) return null;
|
||||
const rs = obj(row.recovery_state) as RecoveryState;
|
||||
if (rs.credential?.status === "not_applicable") return null;
|
||||
const at = this.now();
|
||||
const patch = async (c: Partial<BrowserCredentialState>) => {
|
||||
const cur = obj((await this.sessionRow(id)).recovery_state) as RecoveryState;
|
||||
const base: BrowserCredentialState = cur.credential ?? {
|
||||
status: "none",
|
||||
source: null,
|
||||
sourceAt: null,
|
||||
count: 0,
|
||||
message: null,
|
||||
syncedAt: null,
|
||||
};
|
||||
await this.ctx.db
|
||||
.updateTable("browser_session")
|
||||
.set({
|
||||
recovery_state: JSON.stringify({ ...cur, credential: { ...base, syncedAt: at.toISOString(), ...c } }),
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.execute();
|
||||
};
|
||||
// 先按上游同步当前地址(桌面里的导航不经 API),再判断是否停在登录页
|
||||
if (provider.sessionInfo(id)) {
|
||||
await provider.reconcileTabs(id).catch(() => null);
|
||||
await this.persist(id, provider).catch(() => {});
|
||||
}
|
||||
if (provider.sessionInfo(id)?.loginPage === true) {
|
||||
await patch({});
|
||||
return { changed: false };
|
||||
}
|
||||
const site = await this.liveSite(row.site_id);
|
||||
let header: string | null;
|
||||
try {
|
||||
header = headerFromCookies(
|
||||
await provider.getCookies(row.profile_key, [site.base_url]),
|
||||
site.base_url,
|
||||
Math.floor(at.getTime() / 1000),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.kind === "not_implemented")
|
||||
await patch({ message: "浏览器服务不支持读回 Cookie(镜像未带 umt-cookies 插件),浏览器里的登录不会回写" });
|
||||
return null;
|
||||
}
|
||||
if (!header) {
|
||||
await patch({});
|
||||
return { changed: false };
|
||||
}
|
||||
const r = await writeBrowserCookie(this.ctx, row.account_id, header, at);
|
||||
await patch(
|
||||
r.changed
|
||||
? {
|
||||
source: "browser",
|
||||
sourceAt: at.toISOString(),
|
||||
...(rs.credential?.status === "expired" ? { status: "injected" as const, message: null } : {}),
|
||||
}
|
||||
: {},
|
||||
);
|
||||
if (r.changed)
|
||||
this.ctx.logger.info(
|
||||
{ component: "browser", sessionId: id, why, count: r.count },
|
||||
"浏览器登录态已回写站点账号",
|
||||
);
|
||||
return { changed: r.changed };
|
||||
} catch (e) {
|
||||
this.ctx.logger.warn(
|
||||
{ component: "browser", sessionId: id, why, err: String((e as Error)?.message ?? e) },
|
||||
"浏览器 Cookie 回写失败",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async closeRow(row: BrowserSessionRow, reason: string, prow?: BrowserProviderRow): Promise<void> {
|
||||
const provider = prow ? this.providerFor(prow) : this.providers.get(row.provider_id)?.provider;
|
||||
// 关闭前趁上下文还在读回一次登录态(站点 / 浏览器服务停用时不再访问)
|
||||
if (provider?.sessionInfo(row.id) && reason !== "site_disabled" && reason !== "provider_disabled")
|
||||
await this.syncCookies(row.id, "close");
|
||||
if (provider?.sessionInfo(row.id)) await provider.closeSession(row.id, reason).catch(() => {});
|
||||
const at = this.now();
|
||||
await this.ctx.db.transaction().execute(async (trx) => {
|
||||
@@ -949,6 +1135,8 @@ export class BrowserService {
|
||||
});
|
||||
if (opts.to === "automation") await this.resolveWaiting(row, lease.holder);
|
||||
await this.persist(id, provider);
|
||||
// 人工在桌面 / 截图模式里登录过:交还时读回登录态写进站点账号,全站(HTTP 抓取 / RSS / 快照)生效
|
||||
await this.syncCookies(id, "release");
|
||||
return toLease(released);
|
||||
}
|
||||
|
||||
@@ -1006,9 +1194,22 @@ export class BrowserService {
|
||||
throw fromBrowserError(e);
|
||||
}
|
||||
if (opts.persist !== false) await this.persist(id, loaded.provider, opts.patch?.(out) ?? {});
|
||||
this.maybeSyncCookies(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 有活动时每 COOKIE_SYNC_INTERVAL_MS 读回一次登录态(后台,不拖慢动作;同一会话同时只跑一个) */
|
||||
private readonly syncing = new Set<string>();
|
||||
private maybeSyncCookies(row: BrowserSessionRow): void {
|
||||
if (!row.account_id || this.syncing.has(row.id)) return;
|
||||
const c = (obj(row.recovery_state) as RecoveryState).credential;
|
||||
if (c?.status === "not_applicable") return;
|
||||
const last = c?.syncedAt ? new Date(c.syncedAt).getTime() : new Date(row.created_at).getTime();
|
||||
if (this.now().getTime() - last < COOKIE_SYNC_INTERVAL_MS) return;
|
||||
this.syncing.add(row.id);
|
||||
void this.syncCookies(row.id, "periodic").finally(() => this.syncing.delete(row.id));
|
||||
}
|
||||
|
||||
/** 导航前按站点限流取令牌 */
|
||||
async limited(site: SiteRow): Promise<void> {
|
||||
await this.rateLimit(site);
|
||||
@@ -1350,7 +1551,7 @@ const REASON_TEXT: Record<string, string> = {
|
||||
challenge: "页面需要人工验证",
|
||||
two_factor: "需要二次验证或本人设备确认",
|
||||
challenge_unsolved: "自动处理页面挑战未成功",
|
||||
login_required: "登录已失效",
|
||||
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
|
||||
lease_expired: "人工接管超时未交还",
|
||||
agent_request: "Agent 请求人工处理",
|
||||
human_released_waiting: "人工交还时选择继续等待",
|
||||
|
||||
Reference in new issue
Block a user