mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
fix(gateway): harden GitHub identity lookup coordination
Co-authored-by: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
@@ -16,11 +16,11 @@ OpenClaw loads `USER.md` beside `MEMORY.md` at session start. It has a separate
|
||||
|
||||
Your authenticated Gateway profile is separate from `USER.md`. Open **Settings → Profile → Identity** to set the display name and avatar shown to other people on the Gateway. A custom OpenClaw avatar remains authoritative when a GitHub account is verified.
|
||||
|
||||
GitHub-backed sign-in is supported through Cloudflare Access and Tailscale Serve. For Cloudflare Access, the Gateway accepts identity enrichment only after successful `trusted-proxy` authentication with the standard Access email header and a required Access assertion header. It calls the Access identity endpoint, requires the returned email to match the authenticated proxy principal and the identity provider to be GitHub, then resolves the canonical GitHub login from the returned numeric account id. For Tailscale Serve, the Gateway resolves the verified GitHub-backed Tailscale login through GitHub. Both paths record the immutable numeric account id plus the current canonical login.
|
||||
GitHub-backed sign-in is supported through Cloudflare Access and Tailscale Serve. For Cloudflare Access, the Gateway accepts identity enrichment only after successful `trusted-proxy` authentication with the standard Access email header and a required Access assertion header. It calls the Access identity endpoint, requires the returned email to match the authenticated proxy principal and the identity provider to be GitHub, then resolves the canonical GitHub login from the returned numeric account id. For Tailscale Serve, the Gateway resolves the verified GitHub-backed Tailscale login through GitHub. Both paths record the immutable numeric account id plus the current canonical login. These GitHub lookups use the optional `gateway.controlUi.github.token` service credential when configured, otherwise the Gateway's `GH_TOKEN` then `GITHUB_TOKEN` environment fallback.
|
||||
|
||||
The **GitHub account** row is read-only. Generic trusted proxies, token, password, and unauthenticated connections cannot claim a GitHub account, and agent or tool GitHub credentials are never used for this identity. The forwarded Cloudflare Access assertion is connection-scoped: OpenClaw does not persist, export, log, or expose it to the UI or model.
|
||||
|
||||
Identity lookup runs after WebSocket sign-in, so connection status and other identity-independent reads remain available. Profile and session work waits for the lookup; a Cloudflare or GitHub rate limit or network failure returns retryable unavailability without exposing a mutable alias or erasing a previously verified account. A later request, connection, or Profile refresh retries the lookup. GitHub login renames are reconciled by numeric account id so profile history and preferences stay attached to one person.
|
||||
Identity lookup runs after WebSocket sign-in, so connection status and other identity-independent reads remain available. Profile and session work waits for the lookup; a Cloudflare or GitHub rate limit or network failure returns retryable unavailability without exposing a mutable alias or erasing a previously verified account. Concurrent checks for the same GitHub login share only the active request; a later connection verifies the mutable login again before attaching a profile. Immutable numeric account-id results can be reused during a rate-limit backoff. A later request, connection, or Profile refresh retries the lookup. GitHub login renames are reconciled by numeric account id so profile history and preferences stay attached to one person.
|
||||
|
||||
Public commit metadata is a separate choice. **Git co-author credit** defaults off. Enabling it adds the verified account's public GitHub noreply address to commits created from shared sessions; OpenClaw never requests or stores a private GitHub email for this feature. Signing in as a different numeric GitHub account resets the choice, so one account cannot inherit another account's consent.
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ Verification proves which account answered the GitHub API request. Status report
|
||||
|
||||
Removing an agent override or choosing native credentials deletes the associated local refresh record after the config change. Already-running local processes may retain the old profile and its current access token until they exit, restart, or the token expires, while new runs use the updated identity immediately. This local change does not revoke the authorization at GitHub; revoke it separately from the OAuth application's GitHub settings when required.
|
||||
|
||||
Control UI repository previews and project discovery use the separate optional `gateway.controlUi.github.token` service credential. They never consume an agent tool identity. When this SecretRef is explicit, OpenClaw excludes its exact environment or store name from agent execution. A custom name does not clear unrelated `GH_TOKEN` or `GITHUB_TOKEN` values used by native identity; a ref named `GH_TOKEN` or `GITHUB_TOKEN` excludes that exact variable.
|
||||
GitHub-backed profile verification, Control UI repository previews, and project discovery use the separate optional `gateway.controlUi.github.token` service credential. These Gateway-owned requests never consume an agent tool identity. When this SecretRef is explicit, OpenClaw excludes its exact environment or store name from agent execution. A custom name does not clear unrelated `GH_TOKEN` or `GITHUB_TOKEN` values used by native identity; a ref named `GH_TOKEN` or `GITHUB_TOKEN` excludes that exact variable.
|
||||
|
||||
### `tools.exec`
|
||||
|
||||
|
||||
@@ -848,7 +848,7 @@ Gateway or node host and check `openclaw nodes pending` again.
|
||||
`openclaw config unset gateway.tailscale.preserveFunnel`. Default `false`.
|
||||
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required for public non-loopback browser origins. Private same-origin LAN/Tailnet UI loads from loopback, RFC1918/link-local, `.local`, `.ts.net`, or Tailscale CGNAT hosts are accepted without enabling Host-header fallback.
|
||||
- `controlUi.environment`: optional visual identity for distinguishing Gateway environments. Set `{ label: "edge", color: "amber" }` to show a matching top stripe, agent-avatar ring, environment pills, browser-title suffix, and tinted favicon. `label` is trimmed and must contain 1–24 characters. `color` must be `teal`, `amber`, `purple`, `coral`, `pink`, `blue`, `green`, `red`, or `gray`. The label and color are visible before sign-in; omit the setting to keep the default appearance unchanged.
|
||||
- `controlUi.github.token`: optional SecretRef-backed service credential for Control UI GitHub previews and project discovery. Prefer this explicit setting when the Gateway should own GitHub service access independently of its shared process environment. When omitted, the shipped `GH_TOKEN` then `GITHUB_TOKEN` process-environment fallback remains active. An explicitly configured but unavailable credential fails closed instead of using that fallback. Its exact environment or store name is excluded from agent execution; a custom name does not clear unrelated native `GH_TOKEN` or `GITHUB_TOKEN` values. This credential is separate from `tools.github` agent identities and does not create an OS-user security boundary.
|
||||
- `controlUi.github.token`: optional SecretRef-backed service credential for GitHub-backed profile verification, Control UI previews, and project discovery. Prefer this explicit setting when the Gateway should own GitHub service access independently of its shared process environment. When omitted, the shipped `GH_TOKEN` then `GITHUB_TOKEN` process-environment fallback remains active. An explicitly configured but unavailable credential fails closed for these Gateway-owned GitHub requests instead of using that fallback. Its exact environment or store name is excluded from agent execution; a custom name does not clear unrelated native `GH_TOKEN` or `GITHUB_TOKEN` values. This credential is separate from `tools.github` agent identities and does not create an OS-user security boundary.
|
||||
- `controlUi.toolTitles`: opt in to AI-generated purpose titles for tool calls in Control UI chat. Default: `false` (tool rendering stays fully deterministic with no background model calls). When enabled, the `chat.toolTitles` method labels complex calls through standard utility-model routing — the agent's `utilityModel` (an operator decision that may send bounded tool arguments to the chosen provider, like every utility task), or the session provider's declared small-model default (OpenAI → `gpt-5.6-luna`, Anthropic → `claude-haiku-4-5`) — and caches results in the per-agent state database so repeat views never re-bill. `utilityModel: \"\"` disables titles like every other utility task; titles never fall back to the primary model.
|
||||
- `controlUi.automaticallyFetchFavicons`: link favicons in Control UI chat. Default: `true`. The authenticated browser asks its same-origin Gateway for each hostname. The Gateway requests only `https://<hostname>/favicon.ico`, rejects IP literals and private/internal destinations, pins public DNS results, revalidates every redirect under the same strict SSRF policy, limits redirects/time/bytes/concurrency, validates the image, and returns a private-cacheable image blob. OpenClaw does not use Google or another favicon service for this flow. This discloses linked hostnames and the Gateway's network address to those destination sites. Set `false` to prevent the browser from requesting favicon routes and the Gateway from contacting link destinations.
|
||||
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
|
||||
|
||||
@@ -205,7 +205,7 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
|
||||
"gateway.controlUi.toolTitles":
|
||||
"Opt-in AI purpose titles for tool calls in Control UI chat (default off). When enabled, the chat.toolTitles method generates short titles for complex tool calls with the agent's utility model (an explicit utilityModel may route bounded tool arguments to the operator-chosen provider like every utility task; the derived default stays on the session's provider) and caches them in the per-agent state database. Setting utilityModel to an empty string disables titles too. Leave off to keep tool rendering fully deterministic with no background model calls.",
|
||||
"gateway.controlUi.github.token":
|
||||
"SecretRef-backed service credential for Control UI GitHub previews and project discovery. Prefer explicit configuration for clear service ownership. Omit it to retain the GH_TOKEN/GITHUB_TOKEN fallback from the shared Gateway process environment. An explicitly configured but unavailable credential fails closed. Agent tool identities are never used here.",
|
||||
"SecretRef-backed service credential for GitHub-backed profile verification, Control UI previews, and project discovery. Prefer explicit configuration for clear service ownership. Omit it to retain the GH_TOKEN/GITHUB_TOKEN fallback from the shared Gateway process environment. An explicitly configured but unavailable credential fails closed for these Gateway-owned GitHub requests. Agent tool identities are never used here.",
|
||||
"gateway.controlUi.sessionObserver":
|
||||
"Produce live session status digests for subscribed Control UI clients with each agent's utility model (default on). Set false to disable observer model calls gateway-wide; setting agents.defaults.utilityModel to an empty string disables utility-model observation for agents that do not override it.",
|
||||
"gateway.controlUi.embedSandbox":
|
||||
|
||||
@@ -375,7 +375,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.controlUi.environment.label": "Control UI Environment Label",
|
||||
"gateway.controlUi.environment.color": "Control UI Environment Color",
|
||||
"gateway.controlUi.toolTitles": "Control UI Tool Call Titles",
|
||||
"gateway.controlUi.github.token": "Control UI GitHub Service Credential",
|
||||
"gateway.controlUi.github.token": "GitHub Service Credential",
|
||||
"gateway.controlUi.sessionObserver": "Control UI Session Observer",
|
||||
"gateway.controlUi.root": "Control UI Assets Root",
|
||||
"gateway.controlUi.embedSandbox": "Control UI Embed Sandbox Mode",
|
||||
|
||||
@@ -117,6 +117,20 @@ describe("GitHub rate-limit retry timing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed delta-seconds instead of interpreting exponent notation", async () => {
|
||||
const now = Date.parse("2026-08-23T10:00:00Z");
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const response = new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "1e2" },
|
||||
});
|
||||
|
||||
await expect(readGitHubJsonResponse(response)).rejects.toMatchObject({
|
||||
statusCode: 429,
|
||||
retryAfterMs: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use the primary reset while secondary limiting leaves quota", async () => {
|
||||
const now = Date.parse("2026-08-23T10:00:00Z");
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
|
||||
@@ -210,7 +210,7 @@ function boundedRateLimitDelay(delayMs: number): number | undefined {
|
||||
function githubRateLimitRetryAfterMs(response: Response, now = Date.now()): number {
|
||||
const retryAfter = response.headers.get("retry-after")?.trim();
|
||||
if (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
const seconds = /^\d+$/u.test(retryAfter) ? Number(retryAfter) : Number.NaN;
|
||||
const secondsDelay = boundedRateLimitDelay(seconds * 1_000);
|
||||
if (secondsDelay !== undefined) {
|
||||
return secondsDelay;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { KeyedAsyncQueue } from "../plugin-sdk/keyed-async-queue.js";
|
||||
import { ControlUiGitHubError } from "./control-ui-github-api.js";
|
||||
|
||||
const SUCCESS_CACHE_MS = 5 * 60_000;
|
||||
const RATE_LIMIT_FALLBACK_MS = 60_000;
|
||||
const CAPACITY_RETRY_MS = 1_000;
|
||||
const CACHE_LIMIT = 200;
|
||||
|
||||
export type ResolvedGitHubUserIdentity = { accountId: number; login: string };
|
||||
@@ -15,63 +17,83 @@ type LookupEntry = {
|
||||
class GitHubUserIdentityCoordinator {
|
||||
private readonly backoffs = new Map<string, number>();
|
||||
private readonly lookups = new Map<string, LookupEntry>();
|
||||
private queue = new KeyedAsyncQueue();
|
||||
|
||||
lookup(params: {
|
||||
allowStaleOnRateLimit: boolean;
|
||||
credentialScope: string;
|
||||
identityKind: "account-id" | "login";
|
||||
lookupKey: string;
|
||||
request: () => Promise<ResolvedGitHubUserIdentity>;
|
||||
}): Promise<ResolvedGitHubUserIdentity> {
|
||||
const cacheKey = `${params.credentialScope}:${params.lookupKey}`;
|
||||
const cacheCompleted = params.identityKind === "account-id";
|
||||
const cached = this.touchLookup(cacheKey);
|
||||
if (cached?.value && cached.freshUntil > Date.now()) {
|
||||
if (cacheCompleted && cached?.value && cached.freshUntil > Date.now()) {
|
||||
return Promise.resolve(cached.value);
|
||||
}
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
|
||||
const backoffRemaining = this.backoffRemaining(params.credentialScope);
|
||||
if (backoffRemaining > 0) {
|
||||
// Numeric account IDs are immutable. Login aliases are not, so they must
|
||||
// never use stale verification when GitHub cannot confirm current ownership.
|
||||
if (params.allowStaleOnRateLimit && cached?.value) {
|
||||
if (cacheCompleted && cached?.value) {
|
||||
return Promise.resolve(cached.value);
|
||||
}
|
||||
return Promise.reject(this.rateLimitError(backoffRemaining));
|
||||
}
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
if (!cached) {
|
||||
this.pruneLookups(CACHE_LIMIT - 1);
|
||||
if (this.lookups.size >= CACHE_LIMIT) {
|
||||
return Promise.reject(this.capacityError());
|
||||
}
|
||||
}
|
||||
|
||||
const entry = cached ?? { freshUntil: 0 };
|
||||
const current = params.request().then(
|
||||
(identity) => {
|
||||
const current = this.queue.enqueue(params.credentialScope, async () => {
|
||||
const queuedBackoffRemaining = this.backoffRemaining(params.credentialScope);
|
||||
if (queuedBackoffRemaining > 0) {
|
||||
if (cacheCompleted && entry.value) {
|
||||
return entry.value;
|
||||
}
|
||||
throw this.rateLimitError(queuedBackoffRemaining);
|
||||
}
|
||||
try {
|
||||
const identity = await params.request();
|
||||
if (!cacheCompleted) {
|
||||
return identity;
|
||||
}
|
||||
entry.value = identity;
|
||||
entry.freshUntil = Date.now() + SUCCESS_CACHE_MS;
|
||||
return identity;
|
||||
},
|
||||
(error: unknown) => {
|
||||
} catch (error) {
|
||||
if (error instanceof ControlUiGitHubError && error.statusCode === 429) {
|
||||
const retryAfterMs = error.retryAfterMs ?? RATE_LIMIT_FALLBACK_MS;
|
||||
this.setBackoff(params.credentialScope, retryAfterMs);
|
||||
if (params.allowStaleOnRateLimit && entry.value) {
|
||||
if (cacheCompleted && entry.value) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
if (!entry.value) {
|
||||
this.lookups.delete(cacheKey);
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
entry.inFlight = current;
|
||||
this.lookups.delete(cacheKey);
|
||||
this.lookups.set(cacheKey, entry);
|
||||
this.pruneLookups();
|
||||
void current.then(
|
||||
() => {
|
||||
entry.inFlight = undefined;
|
||||
if (!cacheCompleted) {
|
||||
this.lookups.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
entry.inFlight = undefined;
|
||||
if (!entry.value) {
|
||||
this.lookups.delete(cacheKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
return current;
|
||||
@@ -80,6 +102,7 @@ class GitHubUserIdentityCoordinator {
|
||||
reset(): void {
|
||||
this.backoffs.clear();
|
||||
this.lookups.clear();
|
||||
this.queue = new KeyedAsyncQueue();
|
||||
}
|
||||
|
||||
private backoffRemaining(credentialScope: string): number {
|
||||
@@ -91,8 +114,8 @@ class GitHubUserIdentityCoordinator {
|
||||
return remaining;
|
||||
}
|
||||
|
||||
private pruneLookups(): void {
|
||||
while (this.lookups.size > CACHE_LIMIT) {
|
||||
private pruneLookups(maxSize: number): void {
|
||||
while (this.lookups.size > maxSize) {
|
||||
const oldestIdle = [...this.lookups].find(([, entry]) => !entry.inFlight);
|
||||
if (!oldestIdle) {
|
||||
return;
|
||||
@@ -101,6 +124,14 @@ class GitHubUserIdentityCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private capacityError(): ControlUiGitHubError {
|
||||
return new ControlUiGitHubError(
|
||||
429,
|
||||
`GitHub identity lookup capacity reached; retry after ${CAPACITY_RETRY_MS}ms`,
|
||||
{ retryAfterMs: CAPACITY_RETRY_MS },
|
||||
);
|
||||
}
|
||||
|
||||
private rateLimitError(retryAfterMs: number): ControlUiGitHubError {
|
||||
return new ControlUiGitHubError(
|
||||
429,
|
||||
|
||||
@@ -342,6 +342,100 @@ describe("authenticated GitHub identity sync", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("freshly verifies a mutable login before attaching a later connection", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(githubResponse({ id: 1, login: "ada" }))
|
||||
.mockResolvedValueOnce(githubResponse({ id: 2, login: "ada" }));
|
||||
const createSync = () =>
|
||||
createAuthenticatedGitHubIdentitySync({
|
||||
authResult: {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: "ada@github",
|
||||
tailscaleIdentity: { login: "ada@github", name: "Ada" },
|
||||
},
|
||||
});
|
||||
|
||||
const first = await createSync()?.();
|
||||
const reassigned = await createSync()?.();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(reassigned?.profileId).not.toBe(first?.profileId);
|
||||
expect(getUserProfileListItem(reassigned!.profileId).githubIdentity).toMatchObject({
|
||||
login: "ada",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a distinct lookup when every coordinator slot is in flight", async () => {
|
||||
let releaseRequests: (() => void) | undefined;
|
||||
const requestGate = new Promise<void>((resolve) => {
|
||||
releaseRequests = resolve;
|
||||
});
|
||||
const inFlight = Array.from({ length: 200 }, (_, index) =>
|
||||
githubUserIdentityCoordinator.lookup({
|
||||
credentialScope: "capacity-test",
|
||||
identityKind: "login",
|
||||
lookupKey: `login:user-${index}`,
|
||||
request: async () => {
|
||||
await requestGate;
|
||||
return { accountId: index + 1, login: `user-${index}` };
|
||||
},
|
||||
}),
|
||||
);
|
||||
const overflowRequest = vi.fn().mockResolvedValue({ accountId: 201, login: "overflow" });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
githubUserIdentityCoordinator.lookup({
|
||||
credentialScope: "capacity-test",
|
||||
identityKind: "login",
|
||||
lookupKey: "login:overflow",
|
||||
request: overflowRequest,
|
||||
}),
|
||||
).rejects.toMatchObject({ statusCode: 429, retryAfterMs: 1_000 });
|
||||
expect(overflowRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseRequests?.();
|
||||
await Promise.all(inFlight);
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes distinct lookups that share a credential quota", async () => {
|
||||
let resolveFirst: ((identity: { accountId: number; login: string }) => void) | undefined;
|
||||
const firstRequest = vi.fn(
|
||||
() =>
|
||||
new Promise<{ accountId: number; login: string }>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const secondRequest = vi.fn().mockResolvedValue({ accountId: 2, login: "grace" });
|
||||
|
||||
const first = githubUserIdentityCoordinator.lookup({
|
||||
credentialScope: "shared-quota",
|
||||
identityKind: "login",
|
||||
lookupKey: "login:ada",
|
||||
request: firstRequest,
|
||||
});
|
||||
const second = githubUserIdentityCoordinator.lookup({
|
||||
credentialScope: "shared-quota",
|
||||
identityKind: "login",
|
||||
lookupKey: "login:grace",
|
||||
request: secondRequest,
|
||||
});
|
||||
await vi.waitFor(() => expect(firstRequest).toHaveBeenCalledOnce());
|
||||
expect(secondRequest).not.toHaveBeenCalled();
|
||||
|
||||
resolveFirst?.({ accountId: 1, login: "ada" });
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ accountId: 1, login: "ada" },
|
||||
{ accountId: 2, login: "grace" },
|
||||
]);
|
||||
expect(secondRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves verified identity after lookup failure and retries later", async () => {
|
||||
await withOpenClawTestState({ scenario: "minimal" }, async () => {
|
||||
let now = Date.parse("2026-08-23T10:00:00Z");
|
||||
|
||||
@@ -135,8 +135,8 @@ async function resolveGitHubUserIdentityByLogin(
|
||||
throw new TypeError("GitHub username is invalid");
|
||||
}
|
||||
return githubUserIdentityCoordinator.lookup({
|
||||
allowStaleOnRateLimit: false,
|
||||
credentialScope: credential.cacheScope,
|
||||
identityKind: "login",
|
||||
lookupKey: `login:${requestedLogin}`,
|
||||
request: async () => {
|
||||
const payload = await fetchGitHubIdentityPayload(
|
||||
@@ -165,8 +165,8 @@ function resolveGitHubUserIdentityById(
|
||||
credential: GitHubApiCredentialScope,
|
||||
): Promise<ResolvedGitHubUserIdentity> {
|
||||
return githubUserIdentityCoordinator.lookup({
|
||||
allowStaleOnRateLimit: true,
|
||||
credentialScope: credential.cacheScope,
|
||||
identityKind: "account-id",
|
||||
lookupKey: `id:${accountId}`,
|
||||
request: async () => {
|
||||
const payload = await fetchGitHubIdentityPayload(
|
||||
|
||||
Reference in New Issue
Block a user