mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
fix: register missing web-cookie validators (claude-web, gemini-web, copilot-web, t3-web) (#2793)
Integrated into release/v3.8.6
This commit is contained in:
@@ -3351,6 +3351,231 @@ async function validateAdaptaWebProvider({ apiKey, providerSpecificData = {} }:
|
||||
}
|
||||
}
|
||||
|
||||
async function validateClaudeWebProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const cookieHeader = normalizeSessionCookieHeader(String(apiKey || ""), "sessionKey");
|
||||
if (!cookieHeader) {
|
||||
return { valid: false, error: "Paste your sessionKey cookie from claude.ai" };
|
||||
}
|
||||
|
||||
const { tlsFetchClaude, TlsClientUnavailableError } = await import(
|
||||
"@omniroute/open-sse/services/claudeTlsClient.ts"
|
||||
);
|
||||
|
||||
let response: { status: number; text: string | null };
|
||||
try {
|
||||
response = await tlsFetchClaude("https://claude.ai/api/organizations", {
|
||||
method: "GET",
|
||||
headers: applyCustomUserAgent(
|
||||
{
|
||||
Accept: "application/json",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Cache-Control": "no-cache",
|
||||
Cookie: cookieHeader,
|
||||
Origin: "https://claude.ai",
|
||||
Pragma: "no-cache",
|
||||
Referer: "https://claude.ai/new",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"anthropic-client-platform": "web_claude_ai",
|
||||
},
|
||||
providerSpecificData
|
||||
),
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err instanceof TlsClientUnavailableError) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `${err.message} (claude-web requires this — without it, Cloudflare blocks every request)`,
|
||||
};
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid or expired session cookie — re-paste sessionKey from claude.ai DevTools → Cookies",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Claude.ai unavailable (${response.status})` };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Claude.ai validation failed (${response.status})` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gemini Web cookie validator ──
|
||||
async function validateGeminiWebProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const raw = String(apiKey || "").trim();
|
||||
if (!raw) {
|
||||
return { valid: false, error: "Paste your __Secure-1PSID cookie from gemini.google.com" };
|
||||
}
|
||||
|
||||
// Accept full cookie blob or bare value
|
||||
let cookieHeader = raw;
|
||||
if (!raw.includes("=")) {
|
||||
cookieHeader = `__Secure-1PSID=${raw}`;
|
||||
}
|
||||
|
||||
const response = await validationRead("https://gemini.google.com/app", {
|
||||
headers: applyCustomUserAgent(
|
||||
{
|
||||
Accept: "text/html,application/xhtml+xml",
|
||||
Cookie: cookieHeader,
|
||||
Origin: "https://gemini.google.com",
|
||||
Referer: "https://gemini.google.com/",
|
||||
},
|
||||
providerSpecificData
|
||||
),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid or expired __Secure-1PSID cookie — re-paste from gemini.google.com DevTools → Cookies",
|
||||
};
|
||||
}
|
||||
|
||||
// 200/302 = valid, anything < 500 that isn't auth failure is acceptable
|
||||
if (response.status < 500) {
|
||||
return { valid: true, error: null };
|
||||
}
|
||||
|
||||
return { valid: false, error: `Gemini validation failed (${response.status})` };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copilot Web token validator ──
|
||||
async function validateCopilotWebProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const raw = String(apiKey || "").trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Paste your access_token from copilot.microsoft.com DevTools → Cookies",
|
||||
};
|
||||
}
|
||||
|
||||
// Extract token — may be bare JWT, cookie string with access_token=, or Bearer prefix
|
||||
const { extractAccessToken } = await import("@omniroute/open-sse/executors/copilot-web.ts");
|
||||
const token = extractAccessToken(raw);
|
||||
if (!token) {
|
||||
return { valid: false, error: "Could not extract access_token from input" };
|
||||
}
|
||||
|
||||
// Probe Copilot's conversation API to verify token
|
||||
const response = await validationWrite(
|
||||
"https://copilot.microsoft.com/c/api/conversations?language=en",
|
||||
{
|
||||
method: "GET",
|
||||
headers: applyCustomUserAgent(
|
||||
{
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
Origin: "https://copilot.microsoft.com",
|
||||
Referer: "https://copilot.microsoft.com/",
|
||||
},
|
||||
providerSpecificData
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Invalid or expired access_token — re-paste from copilot.microsoft.com DevTools → Cookies",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `Copilot unavailable (${response.status})` };
|
||||
}
|
||||
|
||||
// 200, 400, 404 etc. all indicate the token was accepted
|
||||
return { valid: true, error: null };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── t3.chat Web cookie validator ──
|
||||
async function validateT3WebProvider({ apiKey, providerSpecificData = {} }: any) {
|
||||
try {
|
||||
const raw = String(apiKey || "").trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Paste your Cookie header and convex-session-id from t3.chat",
|
||||
};
|
||||
}
|
||||
|
||||
// The cookie field may contain "cookies=<Cookie header>\nconvexSessionId=<id>"
|
||||
// or just the Cookie header value. Try to parse.
|
||||
let cookieHeader = raw;
|
||||
let convexSessionId = "";
|
||||
|
||||
if (raw.includes("convexSessionId") || raw.includes("convex-session-id")) {
|
||||
// Structured format: "cookies=...; convexSessionId=..."
|
||||
const parts = raw.split(/[,;\n]/).map((s: string) => s.trim());
|
||||
const cookieParts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith("convexSessionId=") || part.startsWith("convex-session-id=")) {
|
||||
convexSessionId = part.split("=").slice(1).join("=");
|
||||
} else if (part.startsWith("cookies=")) {
|
||||
cookieParts.push(part.slice("cookies=".length));
|
||||
} else if (part.includes("=")) {
|
||||
cookieParts.push(part);
|
||||
}
|
||||
}
|
||||
if (cookieParts.length) cookieHeader = cookieParts.join("; ");
|
||||
}
|
||||
|
||||
// Build final cookie with convex-session-id if found
|
||||
const finalCookie = convexSessionId
|
||||
? `${cookieHeader}; convex-session-id=${convexSessionId}`
|
||||
: cookieHeader;
|
||||
|
||||
const response = await validationRead("https://t3.chat", {
|
||||
headers: applyCustomUserAgent(
|
||||
{
|
||||
Accept: "text/html",
|
||||
Cookie: finalCookie,
|
||||
},
|
||||
providerSpecificData
|
||||
),
|
||||
});
|
||||
|
||||
// t3.chat returns 200/302/404 for valid sessions, 5xx for down
|
||||
if (response.status >= 500) {
|
||||
return { valid: false, error: `t3.chat unavailable (${response.status})` };
|
||||
}
|
||||
|
||||
return { valid: true, error: null };
|
||||
} catch (error: any) {
|
||||
return toValidationErrorResult(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Jules API — GET /v1alpha/sources with X-Goog-Api-Key (see developers.google.com/jules/api). */
|
||||
async function validateJulesProvider({ apiKey }: { apiKey: string }) {
|
||||
try {
|
||||
@@ -3550,6 +3775,10 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
|
||||
"muse-spark-web": validateMuseSparkWebProvider,
|
||||
"inner-ai": validateInnerAiProvider,
|
||||
"adapta-web": validateAdaptaWebProvider,
|
||||
"claude-web": validateClaudeWebProvider,
|
||||
"gemini-web": validateGeminiWebProvider,
|
||||
"copilot-web": validateCopilotWebProvider,
|
||||
"t3-web": validateT3WebProvider,
|
||||
"azure-openai": validateAzureOpenAIProvider,
|
||||
"azure-ai": validateAzureAiProvider,
|
||||
"voyage-ai": ({ apiKey, providerSpecificData }: any) => {
|
||||
|
||||
@@ -2033,6 +2033,309 @@ test("validateCommandCodeProvider rejects auth failures and provider outages", a
|
||||
});
|
||||
});
|
||||
|
||||
// ─── claude-web validator ────────────────────────────────────────────────────
|
||||
|
||||
const { __setTlsFetchOverrideForTesting: __setClaudeTlsFetchOverride } =
|
||||
await import("../../open-sse/services/claudeTlsClient.ts");
|
||||
|
||||
function makeClaudeTlsResponse(status: number, body: string, headers: Record<string, string> = {}): any {
|
||||
const h = new Headers();
|
||||
for (const [k, v] of Object.entries(headers)) h.set(k, v);
|
||||
return { status, ok: status >= 200 && status < 300, headers: h, text: body, body: null };
|
||||
}
|
||||
|
||||
test("claude-web validator: 200 from /api/organizations → valid", async () => {
|
||||
let captured: { url: string; opts: any } | null = null;
|
||||
__setClaudeTlsFetchOverride(async (url, opts) => {
|
||||
captured = { url, opts };
|
||||
return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] }));
|
||||
});
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sessionKey=sk-ant-sid02-test-session-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
assert.equal(captured?.url, "https://claude.ai/api/organizations");
|
||||
assert.match((captured?.opts.headers as Record<string, string>).Cookie || "", /sessionKey=sk-ant-sid02-test-session-key/);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: full cookie blob passes through verbatim", async () => {
|
||||
let capturedCookie = "";
|
||||
__setClaudeTlsFetchOverride(async (_url, opts) => {
|
||||
capturedCookie = (opts.headers as Record<string, string>).Cookie || "";
|
||||
return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] }));
|
||||
});
|
||||
|
||||
const blob =
|
||||
"__cf_bm=abc123; sessionKey=sk-ant-sid02-test; intercom-device-id-lupk8zyo=xyz; __stripe_mid=stripe123";
|
||||
await validateProviderApiKey({ provider: "claude-web", apiKey: blob });
|
||||
assert.equal(capturedCookie, blob);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: 401 → invalid session cookie", async () => {
|
||||
__setClaudeTlsFetchOverride(async () =>
|
||||
makeClaudeTlsResponse(401, JSON.stringify({ error: "unauthorized" }))
|
||||
);
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sessionKey=expired-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Invalid or expired session cookie/i);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: 429 → valid (rate limited means auth passed)", async () => {
|
||||
__setClaudeTlsFetchOverride(async () =>
|
||||
makeClaudeTlsResponse(429, JSON.stringify({ error: "rate limited" }))
|
||||
);
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sessionKey=sk-ant-sid02-good-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: 500 → Claude.ai unavailable", async () => {
|
||||
__setClaudeTlsFetchOverride(async () =>
|
||||
makeClaudeTlsResponse(500, "internal server error")
|
||||
);
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sessionKey=sk-ant-sid02-any-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Claude\.ai unavailable \(500\)/i);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: TLS client unavailable → clear error", async () => {
|
||||
const { TlsClientUnavailableError } = await import("../../open-sse/services/claudeTlsClient.ts");
|
||||
__setClaudeTlsFetchOverride(async () => {
|
||||
throw new TlsClientUnavailableError("tls-client-node not installed");
|
||||
});
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sessionKey=sk-ant-sid02-any-key",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /tls-client-node not installed/i);
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
test("claude-web validator: bare sessionKey value gets prefixed", async () => {
|
||||
let capturedCookie = "";
|
||||
__setClaudeTlsFetchOverride(async (_url, opts) => {
|
||||
capturedCookie = (opts.headers as Record<string, string>).Cookie || "";
|
||||
return makeClaudeTlsResponse(200, JSON.stringify({ orgs: [] }));
|
||||
});
|
||||
|
||||
await validateProviderApiKey({
|
||||
provider: "claude-web",
|
||||
apiKey: "sk-ant-sid02-bare-value",
|
||||
});
|
||||
assert.equal(capturedCookie, "sessionKey=sk-ant-sid02-bare-value");
|
||||
__setClaudeTlsFetchOverride(null);
|
||||
});
|
||||
|
||||
// ─── gemini-web validator ────────────────────────────────────────────────────
|
||||
|
||||
test("gemini-web validator: 200 from gemini.google.com → valid", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
const headers = init.headers || {};
|
||||
if (target.includes("gemini.google.com/app")) {
|
||||
assert.match((headers as Record<string, string>).Cookie || "", /__Secure-1PSID=eyJPSID/);
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "gemini-web",
|
||||
apiKey: "__Secure-1PSID=eyJPSID",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
});
|
||||
|
||||
test("gemini-web validator: bare value gets __Secure-1PSID prefix", async () => {
|
||||
let capturedCookie = "";
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
if (String(url).includes("gemini.google.com")) {
|
||||
capturedCookie = ((init.headers as Record<string, string>) || {}).Cookie || "";
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
};
|
||||
|
||||
await validateProviderApiKey({ provider: "gemini-web", apiKey: "eyJbarevalue" });
|
||||
assert.equal(capturedCookie, "__Secure-1PSID=eyJbarevalue");
|
||||
});
|
||||
|
||||
test("gemini-web validator: 401 → invalid cookie", async () => {
|
||||
globalThis.fetch = async () => new Response("unauthorized", { status: 401 });
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "gemini-web",
|
||||
apiKey: "__Secure-1PSID=expired",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Invalid or expired __Secure-1PSID/i);
|
||||
});
|
||||
|
||||
test("gemini-web validator: 500 → unavailable", async () => {
|
||||
globalThis.fetch = async () => new Response("down", { status: 500 });
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "gemini-web",
|
||||
apiKey: "__Secure-1PSID=eyJany",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Gemini validation failed \(500\)/i);
|
||||
});
|
||||
|
||||
// ─── copilot-web validator ───────────────────────────────────────────────────
|
||||
|
||||
test("copilot-web validator: valid access_token → 200", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
if (target.includes("copilot.microsoft.com/c/api/conversations")) {
|
||||
assert.match(
|
||||
((init.headers as Record<string, string>) || {}).Authorization || "",
|
||||
/Bearer eyJhbGci/
|
||||
);
|
||||
return new Response(JSON.stringify({ conversations: [] }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "copilot-web",
|
||||
apiKey: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
});
|
||||
|
||||
test("copilot-web validator: cookie with access_token= is extracted", async () => {
|
||||
let capturedAuth = "";
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
if (String(url).includes("copilot.microsoft.com")) {
|
||||
capturedAuth = ((init.headers as Record<string, string>) || {}).Authorization || "";
|
||||
return new Response(JSON.stringify({}), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
};
|
||||
|
||||
await validateProviderApiKey({
|
||||
provider: "copilot-web",
|
||||
apiKey: "access_token=eyJhbGciOiJIUzI1NiJ9.payload.sig; other_cookie=foo",
|
||||
});
|
||||
assert.equal(capturedAuth, "Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig");
|
||||
});
|
||||
|
||||
test("copilot-web validator: 401 → invalid token", async () => {
|
||||
globalThis.fetch = async () => new Response("unauthorized", { status: 401 });
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "copilot-web",
|
||||
apiKey: "bad-token",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Invalid or expired access_token/i);
|
||||
});
|
||||
|
||||
test("copilot-web validator: 500 → unavailable", async () => {
|
||||
globalThis.fetch = async () => new Response("down", { status: 500 });
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "copilot-web",
|
||||
apiKey: "any-token",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Copilot unavailable \(500\)/i);
|
||||
});
|
||||
|
||||
test("copilot-web validator: empty input → paste prompt", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("should not fetch");
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({ provider: "copilot-web", apiKey: "" });
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /Paste your access_token/i);
|
||||
});
|
||||
|
||||
// ─── t3-web validator ────────────────────────────────────────────────────────
|
||||
|
||||
test("t3-web validator: valid cookies → valid", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
if (String(url).includes("t3.chat")) {
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "t3-web",
|
||||
apiKey: "cookies=__session=abc123; convexSessionId=def456",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
});
|
||||
|
||||
test("t3-web validator: 500 → unavailable", async () => {
|
||||
globalThis.fetch = async () => new Response("down", { status: 500 });
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "t3-web",
|
||||
apiKey: "cookies=__session=abc",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.match(result.error || "", /t3\.chat unavailable \(500\)/i);
|
||||
});
|
||||
|
||||
test("t3-web validator: valid cookies → passes through", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
if (String(url).includes("t3.chat")) {
|
||||
return new Response("ok", { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(url)}`);
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "t3-web",
|
||||
apiKey: "__session=abc123; convex-session-id=def456",
|
||||
});
|
||||
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
});
|
||||
|
||||
test("llama-cpp is classified as a self-hosted chat provider", async () => {
|
||||
const { isSelfHostedChatProvider, isLocalProvider, providerAllowsOptionalApiKey } =
|
||||
await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
Reference in New Issue
Block a user