feat(browser): fixed Windows identity and per-site egress in umt-cookies plugin (BR-4)
- browser:launching hook generates a Windows fingerprint once (camoufox-js, os=windows), persists it to /root/.camofox/umt-identity.json and reuses it on every launch; Firefox/Camoufox upgrades only bump UA/version fields - Windows fontconfig with absolute font dir (upstream path was broken) - GET /umt/identity (public UA/OS/version) and PUT /umt/sessions/:id/proxy (per-context proxy; Firefox no longer uses container HTTP_PROXY env) - adapter identity()/setEgress(), splitBrowserOnlyCookies for cf_clearance/__cf_bm Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6663afce7e
commit
ebd07c9230
8 files changed
+746
-5
No files matched your search
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 固定浏览器身份(BR-4)的纯函数部分:不依赖 camoufox-js,单测直接导入(packages/adapters/src/browser/umt-identity.test.ts)。
|
||||
*
|
||||
* Camoufox 的指纹是一份 config 字典(navigator.* / screen.* / webGl:* / fonts / 各种 seed …),经 CAMOU_CONFIG_n 环境变量
|
||||
* 交给浏览器进程。身份文件保存"指纹部分"的 config;每次启动把上游生成的 config 里的指纹部分整份换成身份文件里的,
|
||||
* 只保留运行期字段(humanize、locale、geolocation、timezone、WebRTC 地址、扩展路径)。
|
||||
*/
|
||||
|
||||
export const IDENTITY_SCHEMA = 1;
|
||||
|
||||
/** 运行期字段:由 camofox 启动参数决定(CAMOFOX_LOCALE、代理 geoip、humanize、扩展),不进身份文件 */
|
||||
const RUNTIME_KEYS = new Set(["humanize", "addons", "allowMainWorld", "timezone"]);
|
||||
const RUNTIME_PREFIXES = ["humanize:", "locale:", "geolocation:", "webrtc:"];
|
||||
|
||||
export function isRuntimeKey(key) {
|
||||
return RUNTIME_KEYS.has(key) || RUNTIME_PREFIXES.some((p) => key.startsWith(p));
|
||||
}
|
||||
|
||||
/** 从启动参数的 env 里拼回 config(CAMOU_CONFIG_1..n 按序拼接) */
|
||||
export function decodeConfig(env) {
|
||||
const parts = [];
|
||||
for (let i = 1; env && typeof env[`CAMOU_CONFIG_${i}`] === "string"; i++) parts.push(env[`CAMOU_CONFIG_${i}`]);
|
||||
if (!parts.length) return null;
|
||||
try {
|
||||
const v = JSON.parse(parts.join(""));
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把 config 写回 env:先删掉旧的 CAMOU_CONFIG_n,再按 Linux 的分片长度(32767)切片 */
|
||||
export function encodeConfig(env, config) {
|
||||
for (const k of Object.keys(env)) if (/^CAMOU_CONFIG_\d+$/.test(k)) delete env[k];
|
||||
const s = JSON.stringify(config);
|
||||
const chunk = 32767;
|
||||
for (let i = 0; i < s.length; i += chunk) env[`CAMOU_CONFIG_${Math.floor(i / chunk) + 1}`] = s.slice(i, i + chunk);
|
||||
return env;
|
||||
}
|
||||
|
||||
/** 指纹部分(去掉运行期字段) */
|
||||
export function fingerprintPart(config) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(config ?? {})) if (!isRuntimeKey(k)) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 上游生成的 config + 身份 → 实际启动用的 config:运行期字段取上游,其余一律取身份 */
|
||||
export function mergeLaunchConfig(upstream, identityConfig) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(upstream ?? {})) if (isRuntimeKey(k)) out[k] = v;
|
||||
return { ...out, ...fingerprintPart(identityConfig) };
|
||||
}
|
||||
|
||||
/** Firefox 主版本号:UA 里的 rv:NNN.0 / Firefox/NNN.0 */
|
||||
export function firefoxMajorOf(ua) {
|
||||
const m = /(?:rv:|Firefox\/)(\d{2,3})\.\d/.exec(String(ua ?? ""));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** 与 camoufox-js fromBrowserforge 相同的版本号替换:三位数主版本 + ".0" */
|
||||
function bumpString(s, major) {
|
||||
return s.replaceAll(/(?<!\d)(1[0-9]{2})(\.0)(?!\d)/g, `${major}$2`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级:Firefox 主版本变化时只改 UA / 版本相关字段(navigator.* 与 headers.* 里的字符串),其余全部保留;
|
||||
* 新版本 Camoufox 多出来的指纹字段从 sample(同 OS 新生成的一份)补齐,已有字段不动。
|
||||
* 返回 { identity, changed }。
|
||||
*/
|
||||
export function upgradeIdentity(identity, { firefoxVersion, sample, camoufoxVersion, now }) {
|
||||
const cfg = { ...identity.config };
|
||||
let changed = false;
|
||||
if (firefoxVersion && identity.firefoxVersion !== firefoxVersion) {
|
||||
for (const [k, v] of Object.entries(cfg)) {
|
||||
if (typeof v !== "string" || !/^(navigator\.|headers\.)/.test(k)) continue;
|
||||
const nv = bumpString(v, firefoxVersion);
|
||||
if (nv !== v) cfg[k] = nv;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
for (const [k, v] of Object.entries(fingerprintPart(sample ?? {}))) {
|
||||
if (!(k in cfg)) {
|
||||
cfg[k] = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (camoufoxVersion && identity.camoufoxVersion !== camoufoxVersion) changed = true;
|
||||
if (!changed) return { identity, changed: false };
|
||||
return {
|
||||
changed: true,
|
||||
identity: {
|
||||
...identity,
|
||||
config: cfg,
|
||||
firefoxVersion: firefoxVersion ?? identity.firefoxVersion,
|
||||
camoufoxVersion: camoufoxVersion ?? identity.camoufoxVersion,
|
||||
revision: (identity.revision ?? 1) + 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /umt/identity 的公开视图:只含 UA、OS、版本与时间,不含指纹细节 */
|
||||
export function publicIdentity(identity) {
|
||||
const cfg = identity?.config ?? {};
|
||||
return {
|
||||
userAgent: cfg["navigator.userAgent"] ?? null,
|
||||
os: identity?.os ?? null,
|
||||
platform: cfg["navigator.platform"] ?? null,
|
||||
firefoxVersion: identity?.firefoxVersion ?? null,
|
||||
camoufoxVersion: identity?.camoufoxVersion ?? null,
|
||||
fingerprintId: identity?.fingerprintId ?? null,
|
||||
revision: identity?.revision ?? 1,
|
||||
schema: identity?.schema ?? IDENTITY_SCHEMA,
|
||||
createdAt: identity?.createdAt ?? null,
|
||||
updatedAt: identity?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 身份文件结构校验(损坏时重新生成) */
|
||||
export function validIdentity(v) {
|
||||
return (
|
||||
!!v &&
|
||||
typeof v === "object" &&
|
||||
v.schema === IDENTITY_SCHEMA &&
|
||||
v.config &&
|
||||
typeof v.config === "object" &&
|
||||
typeof v.config["navigator.userAgent"] === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点代理(出口 IP 按站点统一):app 传来的 { server, username?, password? } 规范化;null = 直连。
|
||||
* 只接受 http / https / socks4 / socks5,返回 Playwright 的 proxy 形状;非法返回 undefined。
|
||||
*/
|
||||
export function normalizeProxy(p) {
|
||||
if (p === null) return null;
|
||||
if (!p || typeof p !== "object" || typeof p.server !== "string") return undefined;
|
||||
let u;
|
||||
try {
|
||||
u = new URL(p.server);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return undefined;
|
||||
const out = { server: `${u.protocol}//${u.host}` };
|
||||
const username = typeof p.username === "string" && p.username ? p.username : decodeURIComponent(u.username || "");
|
||||
const password = typeof p.password === "string" && p.password ? p.password : decodeURIComponent(u.password || "");
|
||||
if (username) out.username = username;
|
||||
if (password) out.password = password;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sameProxy(a, b) {
|
||||
const k = (p) => (p ? `${p.server}|${p.username ?? ""}|${p.password ?? ""}` : "direct");
|
||||
return k(a) === k(b);
|
||||
}
|
||||
@@ -7,14 +7,166 @@
|
||||
* GET /umt/sessions/:userId/cookies?urls=a,b → 读作用于这些地址的 Cookie(活动上下文优先,否则读落盘文件)
|
||||
* GET /umt/instance → { instanceId, startedAt }:插件加载时生成,进程重启即变
|
||||
* (BR-3:app 据此发现容器重启,把会话标为需要重新连接)
|
||||
* Cookie 值只在请求/响应体里,不写日志(只记个数)。
|
||||
* GET /umt/identity → 固定浏览器身份的公开部分:UA、OS、版本、生成时间(BR-4)
|
||||
* PUT /umt/sessions/:userId/proxy { proxy: {server, username?, password?} | null }
|
||||
* → 该 profile 的出口:站点开代理 = 与 HTTP 同一代理;null = 直连
|
||||
* Cookie 值只在请求/响应体里,不写日志(只记个数);代理只记主机,不记账号口令。
|
||||
*
|
||||
* BR-4 固定浏览器身份(docs/runbook/browser.md §11):`browser:launching` 钩子里,首次用 camoufox-js 生成一份 os=windows
|
||||
* 的指纹 config 落盘到持久卷(缺省 /root/.camofox/umt-identity.json,与 profile 同一个卷),之后每次启动把上游随机生成的
|
||||
* 指纹部分整份换成它;Camoufox / Firefox 升级时只改 UA / 版本字段。字体按 Windows 走 Camoufox 自带的 fontconfig。
|
||||
* 出口(UMT_BROWSER_PROXY_MODE,缺省 site):Firefox 不用容器的 HTTP(S)_PROXY 环境变量(network.proxy.type = 0 直连),
|
||||
* 按 profile 在 `session:creating` 里给 BrowserContext 设代理(Playwright 按上下文代理,Juggler 原生支持);env = 保持旧行为。
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import { getUserPersistencePaths } from "../../lib/persistence.js";
|
||||
import {
|
||||
decodeConfig,
|
||||
encodeConfig,
|
||||
fingerprintPart,
|
||||
firefoxMajorOf,
|
||||
IDENTITY_SCHEMA,
|
||||
mergeLaunchConfig,
|
||||
normalizeProxy,
|
||||
publicIdentity,
|
||||
sameProxy,
|
||||
upgradeIdentity,
|
||||
validIdentity,
|
||||
} from "./identity.js";
|
||||
|
||||
const INSTANCE = { instanceId: randomUUID(), startedAt: new Date().toISOString() };
|
||||
const IDENTITY_OS = "windows";
|
||||
/** 身份里固定的 Firefox 首选项(WebGL2 开关与 WebGL 取样配套) */
|
||||
const IDENTITY_PREFS = ["webgl.enable-webgl2", "webgl.force-enabled"];
|
||||
|
||||
async function camoufoxApi() {
|
||||
const [main, pkg] = await Promise.all([import("camoufox-js"), import("camoufox-js/dist/pkgman.js")]);
|
||||
return { launchOptions: main.launchOptions, installedVerStr: pkg.installedVerStr };
|
||||
}
|
||||
|
||||
/** 用 camoufox-js 按目标 OS 生成一份完整指纹(与上游启动时的生成流程相同,只是 os 固定) */
|
||||
async function sampleIdentity(executablePath) {
|
||||
const { launchOptions } = await camoufoxApi();
|
||||
const out = await launchOptions({
|
||||
os: IDENTITY_OS,
|
||||
headless: false,
|
||||
i_know_what_im_doing: true,
|
||||
exclude_addons: ["UBO"],
|
||||
env: {},
|
||||
...(executablePath ? { executable_path: executablePath } : {}),
|
||||
});
|
||||
const config = fingerprintPart(decodeConfig(out.env) ?? {});
|
||||
const prefs = {};
|
||||
for (const k of IDENTITY_PREFS) if (out.firefoxUserPrefs?.[k] !== undefined) prefs[k] = out.firefoxUserPrefs[k];
|
||||
return { config, prefs };
|
||||
}
|
||||
|
||||
function createIdentityStore({ file, log }) {
|
||||
let cached = null;
|
||||
let loading = null;
|
||||
const save = async (id) => {
|
||||
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(id, null, 2), { mode: 0o600 });
|
||||
await fs.rename(tmp, file);
|
||||
};
|
||||
const load = async (executablePath) => {
|
||||
const { installedVerStr } = await camoufoxApi();
|
||||
let full = null;
|
||||
try {
|
||||
full = installedVerStr();
|
||||
} catch {}
|
||||
const major = full ? String(full).split(".")[0] : null;
|
||||
let id = null;
|
||||
try {
|
||||
const v = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
if (validIdentity(v)) id = v;
|
||||
else {
|
||||
log("warn", "umt identity file invalid; regenerating", { file });
|
||||
await fs.rename(file, `${file}.bad-${Date.now()}`).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== "ENOENT") {
|
||||
log("warn", "umt identity file unreadable; regenerating", { file, error: err.message });
|
||||
await fs.rename(file, `${file}.bad-${Date.now()}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
if (!id) {
|
||||
const s = await sampleIdentity(executablePath);
|
||||
id = {
|
||||
schema: IDENTITY_SCHEMA,
|
||||
os: IDENTITY_OS,
|
||||
config: s.config,
|
||||
prefs: s.prefs,
|
||||
firefoxVersion: firefoxMajorOf(s.config["navigator.userAgent"]) ?? major,
|
||||
camoufoxVersion: full,
|
||||
fingerprintId: createHash("sha256").update(JSON.stringify(s.config)).digest("hex").slice(0, 12),
|
||||
revision: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await save(id);
|
||||
log("info", "umt identity created", {
|
||||
os: id.os,
|
||||
fingerprintId: id.fingerprintId,
|
||||
firefoxVersion: id.firefoxVersion,
|
||||
});
|
||||
} else if ((major && id.firefoxVersion !== major) || (full && id.camoufoxVersion !== full)) {
|
||||
const sample = await sampleIdentity(executablePath).catch(() => null);
|
||||
const r = upgradeIdentity(id, { firefoxVersion: major, sample: sample?.config, camoufoxVersion: full, now });
|
||||
if (r.changed) {
|
||||
id = r.identity;
|
||||
await save(id);
|
||||
log("info", "umt identity upgraded (UA / version fields only)", {
|
||||
fingerprintId: id.fingerprintId,
|
||||
revision: id.revision,
|
||||
firefoxVersion: id.firefoxVersion,
|
||||
});
|
||||
}
|
||||
}
|
||||
return id;
|
||||
};
|
||||
return {
|
||||
get: (executablePath) => {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
loading ??= load(executablePath)
|
||||
.then((id) => {
|
||||
cached = id;
|
||||
return id;
|
||||
})
|
||||
.finally(() => {
|
||||
loading = null;
|
||||
});
|
||||
return loading;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 字体:Camoufox 自带 fontconfig/windows(只含自带字体目录 + Windows 缺省字族映射),但目录写的是相对当前目录的
|
||||
* `fonts`;浏览器进程的当前目录是 camofox 的 /opt/camofox,不是安装目录。复制一份,把字体目录改成绝对路径。
|
||||
*/
|
||||
async function windowsFontconfig(installDir, log) {
|
||||
const src = path.join(installDir, "fontconfig", IDENTITY_OS, "fonts.conf");
|
||||
try {
|
||||
const text = await fs.readFile(src, "utf8");
|
||||
const dir = path.join(os.tmpdir(), "umt-fontconfig-windows");
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(dir, "fonts.conf"),
|
||||
text.replace(/<dir prefix="cwd">fonts<\/dir>/, `<dir>${path.join(installDir, "fonts")}</dir>`),
|
||||
);
|
||||
return dir;
|
||||
} catch (err) {
|
||||
log("warn", "umt identity: windows fontconfig not found; fonts follow upstream", { error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const FIELDS = ["name", "value", "domain", "path", "expires", "httpOnly", "secure", "sameSite"];
|
||||
|
||||
@@ -46,6 +198,110 @@ function matches(c, urls, nowSec) {
|
||||
export async function register(app, ctx) {
|
||||
const { log } = ctx;
|
||||
const profileDir = process.env.CAMOFOX_PROFILE_DIR || ctx.config.profileDir;
|
||||
// 身份与代理表放在 profile 目录的上一级(同一个持久卷 /root/.camofox)
|
||||
const stateDir = process.env.UMT_STATE_DIR || path.dirname(profileDir || "/root/.camofox/profiles");
|
||||
const installDir = process.env.CAMOUFOX_INSTALL_DIR || "/opt/camoufox";
|
||||
const identityMode = (process.env.UMT_BROWSER_IDENTITY || "fixed").toLowerCase();
|
||||
const proxyMode = (process.env.UMT_BROWSER_PROXY_MODE || "site").toLowerCase();
|
||||
const identity = createIdentityStore({
|
||||
file: process.env.UMT_IDENTITY_FILE || path.join(stateDir, "umt-identity.json"),
|
||||
log,
|
||||
});
|
||||
|
||||
// ── BR-4 固定浏览器身份 ──
|
||||
ctx.events.on("browser:launching", async ({ options }) => {
|
||||
if (identityMode === "fixed") {
|
||||
try {
|
||||
const id = await identity.get(options.executablePath);
|
||||
const merged = mergeLaunchConfig(decodeConfig(options.env) ?? {}, id.config);
|
||||
options.env = encodeConfig({ ...(options.env ?? {}) }, merged);
|
||||
const fc = await windowsFontconfig(installDir, log);
|
||||
if (fc) options.env.FONTCONFIG_PATH = fc;
|
||||
options.firefoxUserPrefs = { ...(options.firefoxUserPrefs ?? {}), ...(id.prefs ?? {}) };
|
||||
log("info", "umt identity applied", { os: id.os, fingerprintId: id.fingerprintId, revision: id.revision });
|
||||
} catch (err) {
|
||||
// 身份不可用时照常启动(上游随机指纹),app 读 /umt/identity 失败会退回内置 UA
|
||||
log("error", "umt identity failed; launching with upstream fingerprint", { error: err.message });
|
||||
}
|
||||
}
|
||||
// 出口按站点:不走容器环境变量里的代理;上游自己的代理池(CAMOFOX 代理配置)优先
|
||||
if (proxyMode === "site" && !options.proxy) {
|
||||
options.firefoxUserPrefs = { ...(options.firefoxUserPrefs ?? {}), "network.proxy.type": 0 };
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/umt/identity", ctx.auth(), async (_req, res) => {
|
||||
if (identityMode !== "fixed") return res.status(404).json({ error: "fixed identity disabled" });
|
||||
try {
|
||||
res.json(publicIdentity(await identity.get()));
|
||||
} catch (err) {
|
||||
log("error", "umt identity read failed", { error: err.message });
|
||||
res.status(500).json({ error: ctx.safeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// ── BR-4 出口按站点 ──
|
||||
const proxyFile = path.join(stateDir, "umt-proxies.json");
|
||||
/** userId → 代理(null = 直连);持久化,camofox 重启后 app 重开会话前也会重新下发 */
|
||||
const proxies = new Map();
|
||||
/** userId → 活动上下文创建时实际用的代理 */
|
||||
const applied = new Map();
|
||||
try {
|
||||
const saved = JSON.parse(await fs.readFile(proxyFile, "utf8"));
|
||||
for (const [k, v] of Object.entries(saved ?? {})) {
|
||||
const p = normalizeProxy(v);
|
||||
if (p !== undefined) proxies.set(k, p);
|
||||
}
|
||||
} catch {}
|
||||
const saveProxies = async () => {
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
const tmp = `${proxyFile}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(Object.fromEntries(proxies)), { mode: 0o600 });
|
||||
await fs.rename(tmp, proxyFile);
|
||||
};
|
||||
const hostOf = (p) => (p ? p.server.replace(/^[a-z0-9]+:\/\//, "") : null);
|
||||
|
||||
ctx.events.on("session:creating", ({ userId, contextOptions }) => {
|
||||
if (proxyMode !== "site") return;
|
||||
if (contextOptions.proxy) {
|
||||
applied.set(userId, contextOptions.proxy);
|
||||
return;
|
||||
}
|
||||
const p = proxies.get(userId) ?? null;
|
||||
if (p) contextOptions.proxy = { ...p };
|
||||
applied.set(userId, p);
|
||||
log("info", "umt session egress", { userId, egress: p ? "proxy" : "direct", proxyHost: hostOf(p) });
|
||||
});
|
||||
ctx.events.on("session:destroyed", ({ userId }) => applied.delete(userId));
|
||||
|
||||
app.put("/umt/sessions/:userId/proxy", ctx.auth(), express.json({ limit: "8kb" }), async (req, res) => {
|
||||
try {
|
||||
const userId = ctx.normalizeUserId(req.params.userId);
|
||||
const p = normalizeProxy(req.body?.proxy === undefined ? undefined : req.body.proxy);
|
||||
if (p === undefined) return res.status(400).json({ error: "proxy must be {server} or null" });
|
||||
const before = proxies.has(userId) ? proxies.get(userId) : undefined;
|
||||
proxies.set(userId, p);
|
||||
if (before === undefined || !sameProxy(before, p)) await saveProxies();
|
||||
// 活动上下文的出口与新设置不同:关掉它(持久化插件先落盘登录状态),下次请求按新出口重建
|
||||
let restarted = false;
|
||||
const live = ctx.sessions.get(userId);
|
||||
if (proxyMode === "site" && live && applied.has(userId) && !sameProxy(applied.get(userId), p)) {
|
||||
await ctx.destroySession(userId, { reason: "umt_egress_changed" });
|
||||
restarted = true;
|
||||
}
|
||||
log("info", "umt egress set", {
|
||||
reqId: req.reqId,
|
||||
userId,
|
||||
egress: p ? "proxy" : "direct",
|
||||
proxyHost: hostOf(p),
|
||||
restarted,
|
||||
});
|
||||
res.json({ ok: true, userId, egress: p ? "proxy" : "direct", mode: proxyMode, restarted });
|
||||
} catch (err) {
|
||||
log("error", "umt egress set failed", { reqId: req.reqId, error: err.message });
|
||||
res.status(500).json({ error: ctx.safeError(err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/umt/sessions/:userId/cookies", ctx.auth(), express.json({ limit: "512kb" }), async (req, res) => {
|
||||
try {
|
||||
@@ -108,5 +364,8 @@ export async function register(app, ctx) {
|
||||
|
||||
app.get("/umt/instance", ctx.auth(), (_req, res) => res.json(INSTANCE));
|
||||
|
||||
log("info", "umt-cookies plugin: registered GET/POST /umt/sessions/:userId/cookies, GET /umt/instance");
|
||||
log("info", "umt-cookies plugin: registered cookies / instance / identity / egress endpoints", {
|
||||
identityMode,
|
||||
proxyMode,
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserEgressProxy,
|
||||
BrowserHealth,
|
||||
BrowserIdentity,
|
||||
BrowserSession,
|
||||
BrowserSnapshot,
|
||||
BrowserTab,
|
||||
@@ -175,7 +177,7 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
}
|
||||
|
||||
private async request(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
opts: { query?: Record<string, string | undefined>; body?: Json; timeoutMs?: number; auth?: boolean } = {},
|
||||
): Promise<Response> {
|
||||
@@ -386,6 +388,54 @@ export class CamofoxBrowserProvider implements BrowserProvider {
|
||||
return typeof v === "string" || typeof v === "number" ? String(v) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定浏览器身份(BR-4):umt 插件 `GET /umt/identity`(UA、OS、版本、生成时间,不含指纹细节);
|
||||
* 插件不在或身份关闭(404)返回 null。
|
||||
*/
|
||||
async identity(): Promise<BrowserIdentity | null> {
|
||||
let body: Json;
|
||||
try {
|
||||
body = await this.json(await this.request("GET", "/umt/identity", { timeoutMs: 10_000 }), "/umt/identity");
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.httpStatus === 404) return null;
|
||||
throw e;
|
||||
}
|
||||
const userAgent = str(body.userAgent);
|
||||
if (!userAgent) return null;
|
||||
return {
|
||||
userAgent,
|
||||
os: str(body.os) ?? null,
|
||||
platform: str(body.platform) ?? null,
|
||||
firefoxVersion: str(body.firefoxVersion) ?? null,
|
||||
camoufoxVersion: str(body.camoufoxVersion) ?? null,
|
||||
fingerprintId: str(body.fingerprintId) ?? null,
|
||||
revision: int(body.revision) ?? 1,
|
||||
createdAt: str(body.createdAt) ?? null,
|
||||
updatedAt: str(body.updatedAt) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 该 profile 的出口(BR-4 出口 IP 按站点统一):umt 插件 `PUT /umt/sessions/{userId}/proxy`。
|
||||
* proxy = null 直连;活动上下文出口不同时插件会先关掉它(落盘登录状态)。插件不在报 not_implemented。
|
||||
*/
|
||||
async setEgress(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }> {
|
||||
try {
|
||||
const body = await this.json(
|
||||
await this.request("PUT", `/umt/sessions/${encodeURIComponent(profileKey)}/proxy`, {
|
||||
body: { proxy } as unknown as Json,
|
||||
timeoutMs: 15_000,
|
||||
}),
|
||||
"/umt/sessions/{userId}/proxy",
|
||||
);
|
||||
return { restarted: body.restarted === true };
|
||||
} catch (e) {
|
||||
if (e instanceof BrowserError && e.httpStatus === 404)
|
||||
throw new BrowserError("not_implemented", "浏览器服务不支持按站点设出口(镜像未带新版 umt-cookies 插件)");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建会话的上游上下文(BR-3):上游重启 / 回收后旧标签页都不在了。先(可选)注入 Cookie,再用同一 userId / sessionKey
|
||||
* 开新标签页并导航到 url;旧的打开中标签页标 lost,旧元素引用作废。不检查控制者:接管中的会话重连后仍归原控制者。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserCookie } from "@umt/contracts";
|
||||
import { BROWSER_ONLY_COOKIES, type BrowserCookie } from "@umt/contracts";
|
||||
|
||||
// BR-2 凭据统一:站点账号里存的是 HTTP `Cookie:` 请求头串("a=1; b=2"),浏览器 profile 里是带域名/路径/期限的 Cookie 对象。
|
||||
// 这里只做两者互转与比较;值是凭据,不打印、不进错误信息。
|
||||
@@ -34,6 +34,28 @@ export function mergeCookieHeaders(...headers: (string | null | undefined)[]): s
|
||||
return jar.size ? formatCookieHeader(jar) : null;
|
||||
}
|
||||
|
||||
/** 仅浏览器用的 Cookie(Cloudflare 人机验证):名字精确匹配,或 cf_chl_ 前缀(挑战过程中的临时 Cookie) */
|
||||
export function isBrowserOnlyCookie(name: string): boolean {
|
||||
return (BROWSER_ONLY_COOKIES as readonly string[]).includes(name) || name.startsWith("cf_chl_");
|
||||
}
|
||||
|
||||
/**
|
||||
* 浏览器 → HTTP 方向:剔除仅浏览器用的 Cookie(BR-4)。返回剩下的头串(没有剩余为 null)与被剔除的名字(不含值)。
|
||||
*/
|
||||
export function splitBrowserOnlyCookies(header: string | null | undefined): {
|
||||
header: string | null;
|
||||
browserOnly: string[];
|
||||
} {
|
||||
const jar = parseCookieHeader(header);
|
||||
const browserOnly: string[] = [];
|
||||
for (const name of [...jar.keys()])
|
||||
if (isBrowserOnlyCookie(name)) {
|
||||
jar.delete(name);
|
||||
browserOnly.push(name);
|
||||
}
|
||||
return { header: jar.size ? formatCookieHeader(jar) : null, browserOnly: browserOnly.sort() };
|
||||
}
|
||||
|
||||
/** 两个 Cookie 头串是否等价(忽略顺序与空白) */
|
||||
export function sameCookieHeader(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const x = parseCookieHeader(a);
|
||||
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
BrowserCapabilities,
|
||||
BrowserControlLease,
|
||||
BrowserCookie,
|
||||
BrowserEgressProxy,
|
||||
BrowserHealth,
|
||||
BrowserIdentity,
|
||||
BrowserLeaseHolderKind,
|
||||
BrowserProviderKind,
|
||||
BrowserSession,
|
||||
@@ -90,6 +92,15 @@ export interface BrowserProvider {
|
||||
* 原有打开中的标签页一律标 lost。不检查控制者(接管中的会话也能重连,控制权不变)。
|
||||
*/
|
||||
reopenSession?(sessionId: string, req: ReopenSessionRequest): Promise<ReopenSessionResult>;
|
||||
|
||||
// ── BR-4 浏览器 UA 与指纹统一、出口按站点 ──
|
||||
/** 固定浏览器身份的公开部分(UA、OS、版本);上游没有时返回 null */
|
||||
identity?(): Promise<BrowserIdentity | null>;
|
||||
/**
|
||||
* 该 profile 的出口:proxy = 站点开代理时与 HTTP 同一代理,null = 直连。上游没有接口时报 BrowserError("not_implemented")。
|
||||
* restarted = 活动上下文出口不同、已被关闭(下次操作按新出口重建)。
|
||||
*/
|
||||
setEgress?(profileKey: string, proxy: BrowserEgressProxy | null): Promise<{ restarted: boolean }>;
|
||||
}
|
||||
|
||||
export interface ReopenSessionRequest {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createMockCamofox, MOCK_CAMOFOX_IDENTITY, type MockCamofox } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createCamofoxBrowserProvider, splitBrowserOnlyCookies, staticBrowserSecrets } from "./index.ts";
|
||||
|
||||
// BR-4:umt-cookies 插件的固定身份纯函数(docker/browser-desktop/.../umt-cookies/identity.js)、
|
||||
// 适配器的 identity() / setEgress()、仅浏览器 Cookie 的剔除。
|
||||
|
||||
// 插件是容器里的纯 JS(不进 tsc 模块图):按路径动态导入
|
||||
const PLUGIN = new URL(
|
||||
"../../../../docker/browser-desktop/root/opt/camofox/plugins/umt-cookies/identity.js",
|
||||
import.meta.url,
|
||||
).href;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 插件是无类型 JS
|
||||
const plugin = async (): Promise<any> => import(PLUGIN);
|
||||
|
||||
const UA152 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0";
|
||||
|
||||
describe("umt-cookies 插件:固定身份", () => {
|
||||
it("config ⇄ CAMOU_CONFIG_n 分片往返;旧分片先清掉", async () => {
|
||||
const p = await plugin();
|
||||
const big = { "navigator.userAgent": UA152, pad: "x".repeat(70_000) };
|
||||
const env: Record<string, string> = { CAMOU_CONFIG_1: "stale", CAMOU_CONFIG_9: "stale", PATH: "/bin" };
|
||||
p.encodeConfig(env, big);
|
||||
expect(Object.keys(env).filter((k) => k.startsWith("CAMOU_CONFIG_"))).toEqual([
|
||||
"CAMOU_CONFIG_1",
|
||||
"CAMOU_CONFIG_2",
|
||||
"CAMOU_CONFIG_3",
|
||||
]);
|
||||
expect(env.PATH).toBe("/bin");
|
||||
expect(p.decodeConfig(env)).toEqual(big);
|
||||
expect(p.decodeConfig({})).toBeNull();
|
||||
});
|
||||
|
||||
it("启动 config:运行期字段取上游,指纹字段一律取身份", async () => {
|
||||
const p = await plugin();
|
||||
const upstream = {
|
||||
"navigator.userAgent": "Mozilla/5.0 (X11; Linux x86_64; rv:152.0) Gecko/20100101 Firefox/152.0",
|
||||
"screen.width": 1920,
|
||||
humanize: true,
|
||||
"humanize:maxTime": 2,
|
||||
"locale:language": "zh",
|
||||
"geolocation:latitude": 1,
|
||||
timezone: "Asia/Shanghai",
|
||||
addons: ["/a"],
|
||||
"webrtc:ipv4": "1.2.3.4",
|
||||
};
|
||||
const identity = { "navigator.userAgent": UA152, "navigator.platform": "Win32", "screen.width": 2560 };
|
||||
expect(p.mergeLaunchConfig(upstream, identity)).toEqual({
|
||||
humanize: true,
|
||||
"humanize:maxTime": 2,
|
||||
"locale:language": "zh",
|
||||
"geolocation:latitude": 1,
|
||||
timezone: "Asia/Shanghai",
|
||||
addons: ["/a"],
|
||||
"webrtc:ipv4": "1.2.3.4",
|
||||
"navigator.userAgent": UA152,
|
||||
"navigator.platform": "Win32",
|
||||
"screen.width": 2560,
|
||||
});
|
||||
expect(p.fingerprintPart(upstream)).toEqual({
|
||||
"navigator.userAgent": upstream["navigator.userAgent"],
|
||||
"screen.width": 1920,
|
||||
});
|
||||
});
|
||||
|
||||
it("Firefox 升级:只改 navigator / headers 里的版本号,其余保留;新字段从样本补齐;版本相同不改", async () => {
|
||||
const p = await plugin();
|
||||
const id = {
|
||||
schema: 1,
|
||||
os: "windows",
|
||||
firefoxVersion: "140",
|
||||
camoufoxVersion: "140.0-a",
|
||||
fingerprintId: "fp1",
|
||||
revision: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
config: {
|
||||
"navigator.userAgent": UA152.replaceAll("152.0", "140.0"),
|
||||
"navigator.appVersion": "5.0 (Windows)",
|
||||
"headers.User-Agent": UA152.replaceAll("152.0", "140.0"),
|
||||
"screen.width": 2560,
|
||||
"webGl:renderer": "ANGLE 140.0 build",
|
||||
"canvas:seed": 42,
|
||||
},
|
||||
};
|
||||
const sample = { "navigator.userAgent": "other", "audio:seed": 7, "screen.width": 1 };
|
||||
const r = p.upgradeIdentity(id, { firefoxVersion: "152", sample, camoufoxVersion: "152.0.4", now: "N" });
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.identity.config).toEqual({
|
||||
"navigator.userAgent": UA152,
|
||||
"navigator.appVersion": "5.0 (Windows)",
|
||||
"headers.User-Agent": UA152,
|
||||
"screen.width": 2560,
|
||||
"webGl:renderer": "ANGLE 140.0 build",
|
||||
"canvas:seed": 42,
|
||||
"audio:seed": 7,
|
||||
});
|
||||
expect(r.identity).toMatchObject({ firefoxVersion: "152", revision: 2, fingerprintId: "fp1", updatedAt: "N" });
|
||||
expect(p.publicIdentity(r.identity)).toEqual({
|
||||
userAgent: UA152,
|
||||
os: "windows",
|
||||
platform: null,
|
||||
firefoxVersion: "152",
|
||||
camoufoxVersion: "152.0.4",
|
||||
fingerprintId: "fp1",
|
||||
revision: 2,
|
||||
schema: 1,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "N",
|
||||
});
|
||||
const same = p.upgradeIdentity(r.identity, { firefoxVersion: "152", sample, camoufoxVersion: "152.0.4", now: "M" });
|
||||
expect(same.changed).toBe(false);
|
||||
expect(p.validIdentity(r.identity)).toBe(true);
|
||||
expect(p.validIdentity({ schema: 1, config: {} })).toBe(false);
|
||||
expect(p.firefoxMajorOf(UA152)).toBe("152");
|
||||
});
|
||||
|
||||
it("出口代理规范化:口令从 URL 拆出;非法协议拒绝;null = 直连", async () => {
|
||||
const p = await plugin();
|
||||
expect(p.normalizeProxy(null)).toBeNull();
|
||||
expect(p.normalizeProxy({ server: "http://u%40x:p%[email protected]:7891/" })).toEqual({
|
||||
server: "http://172.17.0.1:7891",
|
||||
username: "u@x",
|
||||
password: "p:w",
|
||||
});
|
||||
expect(p.normalizeProxy({ server: "socks5://h:1080", username: "a", password: "b" })).toEqual({
|
||||
server: "socks5://h:1080",
|
||||
username: "a",
|
||||
password: "b",
|
||||
});
|
||||
expect(p.normalizeProxy({ server: "ftp://h" })).toBeUndefined();
|
||||
expect(p.normalizeProxy({})).toBeUndefined();
|
||||
expect(p.sameProxy(null, null)).toBe(true);
|
||||
expect(p.sameProxy({ server: "http://a:1" }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("仅浏览器 Cookie", () => {
|
||||
it("剔除 cf_clearance / __cf_bm / cf_chl_*,只返回名字", () => {
|
||||
expect(splitBrowserOnlyCookies("a=1; cf_clearance=S; __cf_bm=T; cf_chl_rc_m=1")).toEqual({
|
||||
header: "a=1",
|
||||
browserOnly: ["__cf_bm", "cf_chl_rc_m", "cf_clearance"],
|
||||
});
|
||||
expect(splitBrowserOnlyCookies("cf_clearance=S")).toEqual({ header: null, browserOnly: ["cf_clearance"] });
|
||||
expect(splitBrowserOnlyCookies(null)).toEqual({ header: null, browserOnly: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("camofox 适配器:身份与出口", () => {
|
||||
let mock: MockCamofox | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
const provider = (m: MockCamofox) =>
|
||||
createCamofoxBrowserProvider({
|
||||
providerId: "11111111-1111-4111-8111-111111111111",
|
||||
baseUrl: m.url,
|
||||
secrets: staticBrowserSecrets({ "provider:11111111-1111-4111-8111-111111111111": "k" }),
|
||||
});
|
||||
|
||||
it("identity() 读插件身份;setEgress() 下发代理 / 直连", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k" });
|
||||
const p = provider(mock);
|
||||
expect(await p.identity!()).toMatchObject({ userAgent: MOCK_CAMOFOX_IDENTITY.userAgent, os: "windows" });
|
||||
expect(await p.setEgress!("site-a", { server: "http://127.0.0.1:7891" })).toEqual({ restarted: false });
|
||||
expect(await p.setEgress!("site-b", null)).toEqual({ restarted: false });
|
||||
expect(mock.egress.get("site-a")).toEqual({ server: "http://127.0.0.1:7891" });
|
||||
expect(mock.egress.get("site-b")).toBeNull();
|
||||
});
|
||||
|
||||
it("旧插件(没有接口):identity() 为 null,setEgress() 报 not_implemented", async () => {
|
||||
mock = await createMockCamofox({ accessKey: "k", cookieApi: "none" });
|
||||
const p = provider(mock);
|
||||
expect(await p.identity!()).toBeNull();
|
||||
await expect(p.setEgress!("x", null)).rejects.toMatchObject({ kind: "not_implemented" });
|
||||
});
|
||||
});
|
||||
@@ -304,6 +304,37 @@ export const BrowserCookie = z.object({
|
||||
});
|
||||
export type BrowserCookie = z.infer<typeof BrowserCookie>;
|
||||
|
||||
// ── BR-4 浏览器 UA 与指纹统一 ──
|
||||
|
||||
/** 固定浏览器身份的公开部分(umt 插件 GET /umt/identity):不含指纹细节 */
|
||||
export const BrowserIdentity = z.object({
|
||||
userAgent: z.string().min(1),
|
||||
os: z.string().nullable(),
|
||||
platform: z.string().nullable(),
|
||||
firefoxVersion: z.string().nullable(),
|
||||
camoufoxVersion: z.string().nullable(),
|
||||
/** 首次生成时的指纹摘要(升级只改 UA / 版本,摘要不变) */
|
||||
fingerprintId: z.string().nullable(),
|
||||
/** 每次升级改写 UA / 版本字段 +1 */
|
||||
revision: z.number().int(),
|
||||
createdAt: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
});
|
||||
export type BrowserIdentity = z.infer<typeof BrowserIdentity>;
|
||||
|
||||
/** 浏览器会话出口代理(Playwright 形状);null = 直连 */
|
||||
export interface BrowserEgressProxy {
|
||||
server: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare 的人机验证 Cookie:与取得它的浏览器(UA、IP、TLS 指纹)绑定,HTTP 端(Deno)TLS 指纹不同,拿去用多半无效还可能
|
||||
* 触发风控。浏览器回写账号时剔除,只留在浏览器 profile 里("仅浏览器")。
|
||||
*/
|
||||
export const BROWSER_ONLY_COOKIES = ["cf_clearance", "__cf_bm"] as const;
|
||||
|
||||
/** 站点账号登录态(Cookie)来源:手填 / 账密自动登录 / 浏览器回写 */
|
||||
export const SiteCookieSource = z.enum(["manual", "login", "browser"]);
|
||||
export type SiteCookieSource = z.infer<typeof SiteCookieSource>;
|
||||
|
||||
@@ -126,6 +126,22 @@ export type MockCamofox = MockHttp & {
|
||||
restart(opts?: { loseProfiles?: boolean }): void;
|
||||
/** 当前实例标识 */
|
||||
readonly instanceId: string;
|
||||
/** 各 profile 的出口(`PUT /umt/sessions/:userId/proxy`,BR-4):null = 直连 */
|
||||
egress: Map<string, { server: string; username?: string; password?: string } | null>;
|
||||
};
|
||||
|
||||
/** mock 的固定浏览器身份(`GET /umt/identity`,BR-4) */
|
||||
export const MOCK_CAMOFOX_IDENTITY = {
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0) Gecko/20100101 Firefox/152.0",
|
||||
os: "windows",
|
||||
platform: "Win32",
|
||||
firefoxVersion: "152",
|
||||
camoufoxVersion: "152.0.4-mock",
|
||||
fingerprintId: "mockfp000001",
|
||||
revision: 1,
|
||||
schema: 1,
|
||||
createdAt: "2026-09-30T00:00:00.000Z",
|
||||
updatedAt: "2026-09-30T00:00:00.000Z",
|
||||
};
|
||||
|
||||
/** 1x1 透明 PNG */
|
||||
@@ -331,6 +347,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
const actions: MockCamofoxAction[] = [];
|
||||
const loggedIn = new Set<string>();
|
||||
const cookieJars = new Map<string, MockCamofoxCookie[]>();
|
||||
const egress = new Map<string, { server: string; username?: string; password?: string } | null>();
|
||||
const cookieApi = opts.cookieApi ?? "plugin";
|
||||
let instanceId = `inst-${crypto.randomUUID()}`;
|
||||
/** 重启前存在过的 tabId:之后访问返回 410 */
|
||||
@@ -795,6 +812,21 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
},
|
||||
],
|
||||
[/^\/umt\/instance$/, () => (cookieApi === "plugin" ? { json: { instanceId } } : err(404, "no route"))],
|
||||
[
|
||||
/^\/umt\/identity$/,
|
||||
() => (cookieApi === "plugin" ? { json: { ...MOCK_CAMOFOX_IDENTITY } } : err(404, "no route")),
|
||||
],
|
||||
[
|
||||
/^\/umt\/sessions\/([^/]+)\/proxy$/,
|
||||
(m, _q, req, b) => {
|
||||
if (cookieApi !== "plugin" || req.method !== "PUT") return err(404, "no route");
|
||||
const userId = decodeURIComponent(m[1]!);
|
||||
const p = b.proxy as { server?: string } | null | undefined;
|
||||
if (p !== null && (!p || typeof p.server !== "string")) return err(400, "proxy must be {server} or null");
|
||||
egress.set(userId, p === null ? null : { ...(p as { server: string }) });
|
||||
return { json: { ok: true, userId, egress: p ? "proxy" : "direct", mode: "site", restarted: false } };
|
||||
},
|
||||
],
|
||||
[
|
||||
/^\/umt\/sessions\/([^/]+)\/cookies$/,
|
||||
(m, q, req, b) => {
|
||||
@@ -858,6 +890,7 @@ export async function createMockCamofox(opts: MockCamofoxOptions = {}): Promise<
|
||||
actions,
|
||||
loggedIn,
|
||||
cookieJars,
|
||||
egress,
|
||||
expireLogin(userId?: string) {
|
||||
if (userId) loggedIn.delete(userId);
|
||||
else loggedIn.clear();
|
||||
|
||||
Reference in new issue
Block a user