- 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]>
97 lines
3.9 KiB
TypeScript
97 lines
3.9 KiB
TypeScript
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])));
|
|
}
|