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

+3
View File
@@ -35,6 +35,9 @@ COPY --from=camofox /usr/local/bin/node /usr/local/bin/node
COPY --from=camofox /app /opt/camofox
COPY --from=camofox /root/.cache/camoufox /opt/camoufox
COPY root/ /
# umt-cookies 插件(BR-2:站点账号 Cookie 注入 / 从浏览器读回,访问凭据下的 /umt/sessions/:userId/cookies)
# 上游插件加载器只认 camofox.config.json 的 plugins 清单,这里把它加进去
RUN node -e 'const f="/opt/camofox/camofox.config.json";const c=JSON.parse(require("fs").readFileSync(f,"utf8"));c.plugins={...(c.plugins||{}),"umt-cookies":{enabled:true}};require("fs").writeFileSync(f,JSON.stringify(c,null,2))'
LABEL org.opencontainers.image.title="umt-browser-desktop" \
org.opencontainers.image.description="camofox-browser 1.17.0 (headed, Wayland) on linuxserver baseimage-selkies" \
@@ -27,4 +27,6 @@ chmod 550 "$LABWC_DIR/autostart"
PROFILE_ROOT="$(dirname "${CAMOFOX_PROFILE_DIR:-/root/.camofox/profiles}")"
mkdir -p "${CAMOFOX_PROFILE_DIR:-/root/.camofox/profiles}"
chown -R abc:abc "$PROFILE_ROOT"
# 登录状态含站点 Cookie:目录 0700、文件 0600(旧镜像落盘的是 0644)
chmod -R go-rwx "$PROFILE_ROOT"
chmod 711 /root
@@ -12,6 +12,8 @@ if [[ "${UMT_DESKTOP_IME,,}" == "fcitx5" ]]; then
fi
# Selkies 的手柄/摄像头 LD_PRELOAD 不给浏览器
unset LD_PRELOAD
# profile 落盘文件(storage-state.json 含登录 Cookie)只给运行用户读写
umask 077
cd /opt/camofox
# exec:SIGTERM 直达 node,持久化插件关机落盘(docker/camofox/Dockerfile 同样的教训)
exec s6-setuidgid abc node --max-old-space-size="${MAX_OLD_SPACE_SIZE:-128}" server.js
@@ -0,0 +1,105 @@
/**
* umt-cookies:UMT 给 camofox-browser 1.17.0 加的最小插件(BR-2 凭据统一,docs/runbook/browser.md §9)。
*
* 上游只有 `POST /sessions/:userId/cookies`(导入,要求单独的 CAMOFOX_API_KEY),没有读取端点。本插件在访问凭据
* (CAMOFOX_ACCESS_KEY,ctx.auth())之下提供:
* POST /umt/sessions/:userId/cookies { cookies: Cookie[] } → 写入该 profile 的 BrowserContext 并触发持久化落盘
* GET /umt/sessions/:userId/cookies?urls=a,b → 读作用于这些地址的 Cookie(活动上下文优先,否则读落盘文件)
* Cookie 值只在请求/响应体里,不写日志(只记个数)。
*/
import fs from "node:fs/promises";
import express from "express";
import { getUserPersistencePaths } from "../../lib/persistence.js";
const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"];
function parseUrls(raw) {
const out = [];
for (const s of String(raw || "").split(",")) {
try {
const u = new URL(s.trim());
if (u.protocol === "http:" || u.protocol === "https:") out.push(u);
} catch {}
}
return out.slice(0, 20);
}
/** 与浏览器发送规则一致的最小匹配:域名(host-only / .根域)、路径前缀、secure、未过期 */
function matches(c, urls, nowSec) {
if (typeof c.expires === "number" && c.expires > 0 && c.expires <= nowSec) return false;
const d = String(c.domain || "")
.toLowerCase()
.replace(/^\./, "");
return urls.some((u) => {
const h = u.hostname.toLowerCase();
if (h !== d && !h.endsWith(`.${d}`)) return false;
if (c.secure && u.protocol !== "https:") return false;
return u.pathname.startsWith(c.path || "/");
});
}
export async function register(app, ctx) {
const { log } = ctx;
const profileDir = process.env.CAMOFOX_PROFILE_DIR || ctx.config.profileDir;
app.post("/umt/sessions/:userId/cookies", ctx.auth(), express.json({ limit: "512kb" }), async (req, res) => {
try {
const userId = ctx.normalizeUserId(req.params.userId);
const cookies = req.body?.cookies;
if (!Array.isArray(cookies) || cookies.length > 500) {
return res.status(400).json({ error: "cookies must be an array of at most 500" });
}
const clean = [];
for (const c of cookies) {
if (!c || typeof c !== "object" || typeof c.name !== "string" || !c.name) continue;
if (typeof c.value !== "string" || typeof c.domain !== "string" || !c.domain) continue;
const o = {};
for (const k of FIELDS) if (c[k] !== undefined) o[k] = c[k];
if (!o.path) o.path = "/";
clean.push(o);
}
if (clean.length !== cookies.length) return res.status(400).json({ error: "invalid cookie objects" });
const session = await ctx.getSession(userId);
await session.context.addCookies(clean);
log("info", "umt cookies imported", { reqId: req.reqId, userId, count: clean.length });
// 持久化插件监听此事件立即落盘(与上游导入端点相同)
ctx.events.emit("session:cookies:import", { userId, count: clean.length });
res.json({ ok: true, userId, count: clean.length });
} catch (err) {
log("error", "umt cookie import failed", { reqId: req.reqId, error: err.message });
res.status(500).json({ error: ctx.safeError(err) });
}
});
app.get("/umt/sessions/:userId/cookies", ctx.auth(), async (req, res) => {
try {
const userId = ctx.normalizeUserId(req.params.userId);
const urls = parseUrls(req.query.urls);
if (!urls.length) return res.status(400).json({ error: "urls required" });
const live = ctx.sessions.get(userId);
if (live && !live._closing) {
try {
const cookies = await live.context.cookies(urls.map((u) => u.href));
return res.json({ source: "live", cookies });
} catch {
// 上下文正被关闭:退回读落盘文件
}
}
if (profileDir) {
const { storageStatePath } = getUserPersistencePaths(profileDir, userId);
try {
const state = JSON.parse(await fs.readFile(storageStatePath, "utf8"));
const nowSec = Date.now() / 1000;
const cookies = (Array.isArray(state.cookies) ? state.cookies : []).filter((c) => matches(c, urls, nowSec));
return res.json({ source: "persisted", cookies });
} catch {}
}
res.json({ source: "none", cookies: [] });
} catch (err) {
log("error", "umt cookie read failed", { reqId: req.reqId, error: err.message });
res.status(500).json({ error: ctx.safeError(err) });
}
});
log("info", "umt-cookies plugin: registered GET/POST /umt/sessions/:userId/cookies");
}