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,98 @@
|
||||
/**
|
||||
* BR-2 真机核验:把站点账号存的 Cookie 注入一个全新的 camofox profile → 打开站点首页一次 → 判定是否登录态
|
||||
* (地址不是登录页且页面有 a[href*=logout.php]),再经 umt-cookies 插件读回站点域名的 Cookie 个数。
|
||||
* 只输出布尔值与个数:不截图、不输出页面内容与 Cookie 值;对真实站点只有一次导航。
|
||||
* 用法(只读数据库;camofox 需带 umt-cookies 插件,见 docs/runbook/browser.md §9):
|
||||
* CAMOFOX_URL=http://127.0.0.1:49391 CAMOFOX_ACCESS_KEY=... DATABASE_URL=postgres://... SECRET_MASTER_KEY=... \
|
||||
* deno run -A scripts/browser-verify/cookie-real.ts <site key>
|
||||
* 密钥只从环境变量读,不写进文件。
|
||||
*/
|
||||
import {
|
||||
cookiesFromHeader,
|
||||
createCamofoxBrowserProvider,
|
||||
headerFromCookies,
|
||||
parseCookieHeader,
|
||||
staticBrowserSecrets,
|
||||
} from "@umt/adapters";
|
||||
import type { NavigationBudget } from "@umt/contracts";
|
||||
import { createDb } from "@umt/db";
|
||||
import { createSecretStore } from "../../apps/server/src/modules/auth-settings/provided.ts";
|
||||
import { readAccountCookie } from "../../apps/server/src/modules/sources-identity/service/credentials.ts";
|
||||
|
||||
const env = (k: string) => {
|
||||
const v = Deno.env.get(k);
|
||||
if (!v) throw new Error(`缺少环境变量 ${k}`);
|
||||
return v;
|
||||
};
|
||||
const siteKey = Deno.args[0] ?? "hhanclub";
|
||||
const db = createDb({ connectionString: env("DATABASE_URL"), max: 2 });
|
||||
const secrets = createSecretStore({ db, masterKey: env("SECRET_MASTER_KEY") });
|
||||
const quiet = { info() {}, warn() {}, error() {}, debug() {} } as never;
|
||||
const report: Record<string, unknown> = { siteKey, at: new Date().toISOString() };
|
||||
try {
|
||||
const site = await db.selectFrom("site").selectAll().where("key", "=", siteKey).executeTakeFirstOrThrow();
|
||||
const acc = await db
|
||||
.selectFrom("site_account")
|
||||
.select(["id"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where("enabled", "=", true)
|
||||
.where("auth_kind", "in", ["cookie", "password"])
|
||||
.orderBy("created_at")
|
||||
.executeTakeFirstOrThrow();
|
||||
const cred = await readAccountCookie({ db, secrets, logger: quiet }, acc.id);
|
||||
if (!cred?.header) throw new Error("账号没有 Cookie");
|
||||
report.source = cred.source;
|
||||
report.storedCookieCount = parseCookieHeader(cred.header).size;
|
||||
|
||||
const PROVIDER = "00000000-0000-4000-8000-0000000000b2";
|
||||
const key = env("CAMOFOX_ACCESS_KEY");
|
||||
const p = createCamofoxBrowserProvider({
|
||||
providerId: PROVIDER,
|
||||
baseUrl: env("CAMOFOX_URL"),
|
||||
secrets: staticBrowserSecrets({ [`provider:${PROVIDER}`]: key }),
|
||||
});
|
||||
const host = new URL(site.base_url).hostname;
|
||||
const budget: NavigationBudget = { domainAllowlist: [host], timeoutMs: 45_000, maxRetries: 0, maxBytes: 512 * 1024 };
|
||||
// 全新 profile:证明登录态来自注入,不是 profile 里原有的
|
||||
const profileKey = `umt-br2-verify-${Date.now()}`;
|
||||
const t0 = Date.now();
|
||||
const opened = await p.openSession({
|
||||
providerId: PROVIDER,
|
||||
siteId: site.id,
|
||||
accountId: acc.id,
|
||||
profileKey,
|
||||
initialUrl: site.base_url,
|
||||
budget,
|
||||
cookies: cookiesFromHeader(cred.header, site.base_url),
|
||||
});
|
||||
report.inject = opened.cookies;
|
||||
report.openMs = Date.now() - t0;
|
||||
report.httpStatus = opened.navigation?.httpStatus ?? null;
|
||||
const info = p.sessionInfo(opened.session.id);
|
||||
report.loginPage = info?.loginPage ?? null;
|
||||
report.landedPath = opened.session.currentUrl ? new URL(opened.session.currentUrl).pathname : null;
|
||||
// 登录态标记:与站点定义 umt.loginSuccess(a[href='logout.php'])一致;只取布尔值
|
||||
const res = await fetch(`${env("CAMOFOX_URL")}/tabs/${encodeURIComponent(opened.tab.upstreamTabId)}/evaluate`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId: profileKey,
|
||||
expression: `!!document.querySelector("a[href*='logout.php']")`,
|
||||
}),
|
||||
});
|
||||
report.logoutLinkPresent = res.ok
|
||||
? ((await res.json()) as { result?: unknown }).result === true
|
||||
: `HTTP ${res.status}`;
|
||||
report.loggedIn = report.logoutLinkPresent === true && report.loginPage === false;
|
||||
const back = await p.getCookies(profileKey, [site.base_url]);
|
||||
report.readBackCookieCount = back.length;
|
||||
report.readBackDomains = [...new Set(back.map((c) => c.domain))];
|
||||
const header = headerFromCookies(back, site.base_url);
|
||||
report.readBackContainsInjected = header
|
||||
? [...parseCookieHeader(cred.header).keys()].every((k) => parseCookieHeader(header).has(k))
|
||||
: false;
|
||||
await p.closeSession(opened.session.id, "verify_done");
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。2026-09-29 真实账号核对:列表标题链接里带站点的新种标记「[新]」(详情页标题没有),用 replace 过滤掉;等级「贵宾」为 VIP;访问默认按低频原则收紧(快照只抓第 1 页、健康检测 6 小时、详情缓存 24 小时、搜索并发 1)。",
|
||||
"$comment": "HHClub 分类名取自 MovieClaw hhanclub.yaml 注释(导航栏分类表);REQ §4.5 要求综艺/体育/短剧保留原始分类。",
|
||||
"umt": {
|
||||
"categoryNames": {
|
||||
"401": "电影",
|
||||
@@ -12,37 +12,6 @@
|
||||
"410": "音乐",
|
||||
"411": "MV",
|
||||
"412": "短剧"
|
||||
},
|
||||
"version": "[email protected]+umt.1",
|
||||
"rateLimit": {
|
||||
"minIntervalMs": 3000,
|
||||
"burst": 2
|
||||
},
|
||||
"vipLevels": ["贵宾", "VIP"],
|
||||
"defaults": {
|
||||
"snapshotIntervalSec": 3600,
|
||||
"snapshotMaxPages": 1,
|
||||
"healthCheckIntervalSec": 21600,
|
||||
"userDataIntervalSec": 21600,
|
||||
"searchConcurrency": 1,
|
||||
"detailCacheSec": 86400
|
||||
}
|
||||
},
|
||||
"torrents": {
|
||||
"fields": {
|
||||
"title": {
|
||||
"text": "{% if fields['title_optional'] %}{{ fields['title_optional'] }}{% else %}{{ fields['title_default'] }}{% endif %}",
|
||||
"filters": [
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["[新]", ""]
|
||||
},
|
||||
{
|
||||
"name": "replace",
|
||||
"args": ["(新)", ""]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user