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:
rootandClaude Opus 5.5 committed 2026-09-29 15:25:07 +08:00
1 parent 901b01d1a1
commit 28974d1e1a
58 files changed
+2843 -1112

No files matched your search

@@ -175,15 +175,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
.where("key", "=", `site:${siteId}`)
.executeTakeFirst();
expect(bucket).toMatchObject({ capacity: 5, refill_per_sec: 200 });
// 手动快照要 5 页也只抓访问默认的页数上限(settings.sites.defaultSnapshotMaxPages 缺省 1)
expect((await snapshotSite(h.rt, { siteId, kind: "manual", maxPages: 5 })).pages).toEqual([1]);
// 每拿到一个令牌计一次请求,按小时聚合;站点页读最近 24 小时
await new Promise((r) => setTimeout(r, 50));
const stats = await json(await h.req(`/sites/${siteId}/request-stats`));
expect(stats.status).toBe(200);
expect(stats.body.data.last24h).toBeGreaterThanOrEqual(4);
expect(stats.body.data.hourly).toHaveLength(24);
expect(stats.body.data.access).toMatchObject({ snapshotMaxPages: 1, healthCheckIntervalSec: 21600 });
});
it("工作台列表:快照优先,标注来源与更新时间;筛选与分页", async () => {
@@ -228,13 +219,6 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
expect(d.body.data.refreshError).toBeNull();
expect(d.body.data.volatile.recheckBeforeDownload).toBe(true);
expect(d.body.data.volatile.detailFetchedAt).not.toBeNull();
// 详情缓存期内(settings.sites.defaultDetailCacheSec 缺省 24 小时)再次刷新不请求站点
const before = mock.requests.length;
const cached = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(cached.body.data.refreshError).toBeNull();
expect(mock.requests.length).toBe(before);
// 缓存过期后才重新抓
await h.db.updateTable("source_release").set({ detail_fetched_at: null }).where("id", "=", rel.id).execute();
mock.setLoggedIn(false);
const d2 = await json(await h.req(`/sites/${siteId}/releases/${rel.id}?refresh=true`));
expect(d2.status).toBe(200);
@@ -0,0 +1,125 @@
import { sameCookieHeader } from "@umt/adapters";
import { createMockNexusPhpSite, type MockNexusPhpSite } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
readAccountCookie,
registerBrowserCookieReader,
writeBrowserCookie,
} from "../../src/modules/sources-identity/service/credentials.ts";
import { createHarness, type Harness, pgAvailable } from "./harness.ts";
// BR-2 凭据单一入口(mock NexusPHP 登录态判定):来源判定(手填 / 自动登录 / 浏览器回写)、回写只在有变化时写,
// refresh-login 发现 Cookie 失效时从浏览器 profile 读回试一次,有效才回写。
const up = await pgAvailable();
// biome-ignore lint/suspicious/noExplicitAny: 响应体断言
const json = async (r: Response): Promise<any> => {
const body = await r.json();
if (!r.ok) throw new Error(`${r.status} ${JSON.stringify(body)}`);
return body.data;
};
describe.skipIf(!up)("BR-2 站点账号 Cookie 单一入口", () => {
let h: Harness;
let mock: MockNexusPhpSite;
let siteId: string;
let accountId: string;
let browserCookie: string | null = null;
beforeAll(async () => {
mock = await createMockNexusPhpSite({ username: "alice", password: "s3cret-pw" });
h = await createHarness("br2cred");
// 测试用 secret 替身不记 rotated_at:补上(来源判定依赖它)
const replace = h.secrets.replace.bind(h.secrets);
h.secrets.replace = async (ref, plaintext) => {
await replace(ref, plaintext);
await h.db.updateTable("secret").set({ rotated_at: new Date() }).where("id", "=", ref).execute();
};
const site = await json(
await h.req("/sites", {
body: {
key: "audiences",
name: "观众",
adapter: "nexusphp",
baseUrl: `${mock.baseUrl}/`,
rateLimit: { minIntervalMs: 0, concurrency: 10 },
},
}),
);
siteId = site.id;
registerBrowserCookieReader(h.ctx, async () => browserCookie);
});
afterAll(async () => {
await h?.close();
await mock?.close();
});
const refresh = async () =>
(await json(await h.req(`/sites/${siteId}/refresh-login`, { body: { wait: true, accountId } }))).result;
it("手填 Cookie → 来源 manual;浏览器回写 → browser;再手填 → manual", async () => {
const r = await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
}),
);
accountId = r.siteAccountId;
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual", applicable: true });
expect(await writeBrowserCookie(h.ctx, accountId, "c_secure_pass=stale; c_secure_uid=1")).toMatchObject({
changed: false,
});
const w = await writeBrowserCookie(h.ctx, accountId, "c_secure_uid=1; c_secure_pass=from-browser");
expect(w).toMatchObject({ changed: true, count: 2 });
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({
source: "browser",
header: "c_secure_pass=from-browser; c_secure_uid=1",
});
await new Promise((r) => setTimeout(r, 10));
await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "cookie", cookie: "c_secure_uid=1; c_secure_pass=stale" },
}),
);
// cookieOrigin 记的时间比 secret 最近写入早 5 秒以上才判手填;测试里直接把回写时间拨早
await h.db
.updateTable("site_account")
.set({
user_info: JSON.stringify({ cookieOrigin: { source: "browser", by: "browser", at: "2020-01-01T00:00:00Z" } }),
})
.where("id", "=", accountId)
.execute();
expect(await readAccountCookie(h.ctx, accountId)).toMatchObject({ source: "manual" });
});
it("refresh-login:存的 Cookie 失效,浏览器里有有效登录态 → 验证通过后回写,账号 active", async () => {
browserCookie = "c_secure_uid=1; c_secure_pass=also-stale";
const bad = await refresh();
expect(bad).toMatchObject({ ok: false, status: "expired" });
browserCookie = mock.cookie;
const ok = await refresh();
expect(ok).toMatchObject({ ok: true, status: "active" });
const acc = await readAccountCookie(h.ctx, accountId);
expect(acc).toMatchObject({ source: "browser" });
expect(acc!.header).toContain("mock-session-secret");
// 之后 HTTP 抓取取凭证走同一存储
const used = await h.rt["siteSecrets"](siteId).resolve({ siteId, kind: "cookie" });
expect(sameCookieHeader(used, acc!.header)).toBe(true);
});
it("API Key 账号不适用", async () => {
const r = await json(
await h.req(`/sites/${siteId}/credentials`, {
method: "PUT",
body: { kind: "api_key", label: "k", apiKey: "k-123" },
}),
);
expect(await readAccountCookie(h.ctx, r.siteAccountId)).toMatchObject({ applicable: false, header: null });
expect(await writeBrowserCookie(h.ctx, r.siteAccountId, "a=1")).toMatchObject({ changed: false });
});
});
@@ -1,48 +0,0 @@
import type { SiteRelease } from "@umt/adapters";
import { describe, expect, it } from "vitest";
import { mergeDetailOverList } from "../../src/modules/sources-identity/service/snapshot.ts";
// HHClub 2026-09-29 实测:div 详情页没有做种 / 完成数 / 分类名,刷新详情后这些列被 null 冲掉。
describe("详情刷新沿用列表字段", () => {
const detail: SiteRelease = {
siteId: "hhanclub",
siteReleaseId: "91001",
title: "示例短剧 1080p WEB-DL H265 AAC-HHWEB",
category: { id: "412", name: null },
sizeBytes: 194385019,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
hr: "unknown",
uploadedAt: "2026-09-29T06:07:03.000Z",
detailUrl: "https://hhanclub.net/details.php?id=91001",
downloadUrl: "https://hhanclub.net/download.php?id=91001",
tags: [],
raw: {},
observedAt: "2026-09-29T06:19:28.000Z",
};
const rel = {
seeders: 1,
leechers: 58,
category_raw: "412",
tags_raw: ["官方"],
raw: { _site: { hr: false, snatched: 0, categoryName: "短剧" } },
};
it("详情缺的做种 / 下载 / 完成 / 分类名 / H&R / 标签取列表快照值", () => {
expect(mergeDetailOverList(detail, rel)).toMatchObject({
seeders: 1,
leechers: 58,
snatched: 0,
category: { id: "412", name: "短剧" },
hr: false,
tags: ["官方"],
});
});
it("详情有值时以详情为准", () => {
const m = mergeDetailOverList({ ...detail, seeders: 9, hr: true, category: { id: "402", name: "电视剧" } }, rel);
expect(m).toMatchObject({ seeders: 9, hr: true, category: { id: "402", name: "电视剧" } });
});
});