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,96 @@
|
||||
import type { BrowserCookie } from "@umt/contracts";
|
||||
|
||||
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
|
||||
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
|
||||
|
||||
/** 注入时 Cookie 头串里没有期限信息:给 30 天,站点响应的 Set-Cookie 会按站点自己的期限覆盖 */
|
||||
export const INJECTED_COOKIE_TTL_SEC = 30 * 24 * 3600;
|
||||
|
||||
/** "a=1; b=2" → [[a, 1], [b, 2]](同名后者覆盖;去掉空段与首尾空白) */
|
||||
export function parseCookieHeader(header: string | null | undefined): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const part of (header ?? "").split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i <= 0) continue;
|
||||
const name = part.slice(0, i).trim();
|
||||
if (!name || /[\s,;]/.test(name)) continue;
|
||||
out.set(name, part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Map → "a=1; b=2"(按名字排序,比较稳定) */
|
||||
export function formatCookieHeader(jar: Map<string, string>): string {
|
||||
return [...jar.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
/** 多个 Cookie 头串合并,靠后的优先(用户填的 + 自动登录得到的会话 Cookie → 会话 Cookie 覆盖同名项) */
|
||||
export function mergeCookieHeaders(...headers: (string | null | undefined)[]): string | null {
|
||||
const jar = new Map<string, string>();
|
||||
for (const h of headers) for (const [k, v] of parseCookieHeader(h)) jar.set(k, v);
|
||||
return jar.size ? formatCookieHeader(jar) : null;
|
||||
}
|
||||
|
||||
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
|
||||
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const x = parseCookieHeader(a);
|
||||
const y = parseCookieHeader(b);
|
||||
if (x.size !== y.size) return false;
|
||||
for (const [k, v] of x) if (y.get(k) !== v) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 站点地址 → 注入用 Cookie 对象:域名 = 站点主机(host-only),路径 /,https 站点标 secure */
|
||||
export function cookiesFromHeader(
|
||||
header: string,
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): BrowserCookie[] {
|
||||
const u = new URL(siteUrl);
|
||||
const secure = u.protocol === "https:";
|
||||
return [...parseCookieHeader(header)].map(([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
domain: u.hostname,
|
||||
path: "/",
|
||||
expires: nowSec + INJECTED_COOKIE_TTL_SEC,
|
||||
httpOnly: false,
|
||||
secure,
|
||||
sameSite: "Lax" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cookie 的 domain 是否作用于该主机(host-only 精确匹配;.根域 匹配子域) */
|
||||
export function cookieDomainMatches(cookieDomain: string, host: string): boolean {
|
||||
const d = cookieDomain.toLowerCase().replace(/^\./, "");
|
||||
const h = host.toLowerCase();
|
||||
return h === d || h.endsWith(`.${d}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器读回的 Cookie → 站点 Cookie 头串。只取作用于站点主机、路径为根(或站点路径前缀)且未过期的,
|
||||
* 其它域名(统计、CDN、第三方)的 Cookie 一律丢弃(回写只对该站域名)。
|
||||
*/
|
||||
export function headerFromCookies(
|
||||
cookies: readonly BrowserCookie[],
|
||||
siteUrl: string,
|
||||
nowSec = Math.floor(Date.now() / 1000),
|
||||
): string | null {
|
||||
const u = new URL(siteUrl);
|
||||
const jar = new Map<string, { v: string; specific: number }>();
|
||||
for (const c of cookies) {
|
||||
if (!cookieDomainMatches(c.domain, u.hostname)) continue;
|
||||
if (c.expires !== undefined && c.expires > 0 && c.expires <= nowSec) continue;
|
||||
const path = c.path || "/";
|
||||
if (!u.pathname.startsWith(path)) continue;
|
||||
// 同名多条:更具体的域名 / 路径优先(与浏览器发送顺序一致)
|
||||
const specific = c.domain.replace(/^\./, "").length * 1000 + path.length;
|
||||
const prev = jar.get(c.name);
|
||||
if (!prev || specific >= prev.specific) jar.set(c.name, { v: c.value, specific });
|
||||
}
|
||||
if (!jar.size) return null;
|
||||
return formatCookieHeader(new Map([...jar].map(([k, x]) => [k, x.v])));
|
||||
}
|
||||
Reference in new issue
Block a user