운영 보안을 보존하여 빌드 개선 배포본 통합
This commit is contained in:
@@ -880,56 +880,66 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
|
||||
|
||||
const installRealtimeHarness = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
class TestEventSource extends EventTarget {
|
||||
static latest: TestEventSource | null = null;
|
||||
static created = 0;
|
||||
static closed = 0;
|
||||
readonly url: string;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = url.toString();
|
||||
TestEventSource.created += 1;
|
||||
TestEventSource.latest = this;
|
||||
queueMicrotask(() => this.dispatchEvent(new Event('open')));
|
||||
}
|
||||
|
||||
close() {
|
||||
if (TestEventSource.latest === this) {
|
||||
TestEventSource.latest = null;
|
||||
TestEventSource.closed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: TestEventSource });
|
||||
Object.defineProperty(window, '__emitMainRealtime', {
|
||||
configurable: true,
|
||||
value: (type: string, payload: unknown) => {
|
||||
TestEventSource.latest?.dispatchEvent(
|
||||
new MessageEvent(type, {
|
||||
data: JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
let latest: { emit: (type: string, payload: unknown) => void; close: () => void } | null = null;
|
||||
let created = 0;
|
||||
let closed = 0;
|
||||
let authenticated = false;
|
||||
let credentialInUrl = false;
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
||||
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
|
||||
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
authenticated = new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false;
|
||||
credentialInUrl = url.searchParams.has('token');
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
let ended = false;
|
||||
const source = {
|
||||
close: () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
controller.close();
|
||||
},
|
||||
emit: (type: string, payload: unknown) => {
|
||||
if (ended) return;
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
`event: ${type}\ndata: ${JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
})}\n\n`
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (next) => {
|
||||
controller = next;
|
||||
},
|
||||
cancel: () => {
|
||||
if (!ended) {
|
||||
ended = true;
|
||||
closed += 1;
|
||||
if (latest === source) latest = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
latest = source;
|
||||
init?.signal?.addEventListener('abort', source.close, { once: true });
|
||||
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
};
|
||||
Object.assign(window, {
|
||||
__emitMainRealtime: (type: string, payload: unknown) => {
|
||||
latest?.emit(type, payload);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(window, '__hasMainRealtime', {
|
||||
configurable: true,
|
||||
value: () => TestEventSource.latest !== null,
|
||||
});
|
||||
Object.defineProperty(window, '__mainRealtimeStats', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
active: TestEventSource.latest !== null,
|
||||
created: TestEventSource.created,
|
||||
closed: TestEventSource.closed,
|
||||
}),
|
||||
__hasMainRealtime: () => latest !== null,
|
||||
__mainRealtimeStats: () => ({ active: latest !== null, created, closed, authenticated, credentialInUrl }),
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -5430,10 +5440,53 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__mainRealtimeStats: () => { authenticated: boolean; credentialInUrl: boolean };
|
||||
}
|
||||
).__mainRealtimeStats()
|
||||
)
|
||||
).toMatchObject({ authenticated: true, credentialInUrl: false });
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders).toContain(null);
|
||||
|
||||
const captureRefreshLayout = async (phase: string): Promise<void> => {
|
||||
if (!autoRefreshArtifactRoot) return;
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
const layout = await page.evaluate(() => ({
|
||||
url: location.href,
|
||||
viewport: { width: innerWidth, height: innerHeight, dpr: devicePixelRatio },
|
||||
cards: Array.from(document.querySelectorAll('[data-main-target="general"], [data-main-target="city"]')).map(
|
||||
(element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
target: element.getAttribute('data-main-target'),
|
||||
bounds: element.getBoundingClientRect().toJSON(),
|
||||
fontFamily: style.fontFamily,
|
||||
fontSize: style.fontSize,
|
||||
color: style.color,
|
||||
backgroundColor: style.backgroundColor,
|
||||
display: style.display,
|
||||
};
|
||||
}
|
||||
),
|
||||
images: Array.from(document.images).map((image) => ({
|
||||
src: image.getAttribute('src'),
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
complete: image.complete,
|
||||
objectFit: getComputedStyle(image).objectFit,
|
||||
})),
|
||||
}));
|
||||
await Promise.all([
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-layout.json`), JSON.stringify(layout, null, 2)),
|
||||
writeFile(resolve(autoRefreshArtifactRoot, `${phase}-dom.html`), await page.content()),
|
||||
]);
|
||||
};
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ tournament: true }));
|
||||
@@ -5473,6 +5526,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
}).observe({ entryTypes: ['measure'] });
|
||||
});
|
||||
|
||||
await captureRefreshLayout('before-refresh');
|
||||
const callsBeforeRefresh = state.generalMeCalls;
|
||||
const operationsBeforeClockOnly = state.operations.length;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
@@ -5616,6 +5670,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
vueMeasures: probe.vueMeasures.filter((name) => /render|patch/u.test(name)),
|
||||
};
|
||||
});
|
||||
await captureRefreshLayout('after-refresh');
|
||||
expect(profile.generalMounted).toBe(true);
|
||||
expect(profile.cityMounted).toBe(true);
|
||||
expect(profile.generalMutations).toBeGreaterThan(0);
|
||||
|
||||
@@ -371,37 +371,57 @@ const installFixture = async (
|
||||
|
||||
const installFakeEventSource = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
class FakeEventSource extends EventTarget {
|
||||
static instances: FakeEventSource[] = [];
|
||||
readonly url: string;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = String(url);
|
||||
FakeEventSource.instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
if (!this.closed) this.dispatchEvent(new Event('open'));
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown) {
|
||||
if (this.closed) return;
|
||||
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
const sources: Array<{
|
||||
url: string;
|
||||
closed: boolean;
|
||||
authenticated: boolean;
|
||||
emit: (type: string, payload: unknown) => void;
|
||||
}> = [];
|
||||
window.fetch = (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input), location.href);
|
||||
if (!url.pathname.endsWith('/events')) return originalFetch(input, init);
|
||||
if (init?.signal?.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const source = {
|
||||
url: url.href,
|
||||
closed: false,
|
||||
authenticated: new Headers(init?.headers).get('authorization')?.startsWith('Bearer ga_') ?? false,
|
||||
emit: (type: string, payload: unknown) => {
|
||||
if (!source.closed)
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`)
|
||||
);
|
||||
},
|
||||
};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start: (next) => {
|
||||
controller = next;
|
||||
},
|
||||
cancel: () => {
|
||||
source.closed = true;
|
||||
},
|
||||
});
|
||||
sources.push(source);
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
if (!source.closed) {
|
||||
source.closed = true;
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
return Promise.resolve(new Response(body, { headers: { 'content-type': 'text/event-stream' } }));
|
||||
};
|
||||
Object.assign(window, {
|
||||
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
|
||||
__tournamentEventSourceUrls: () =>
|
||||
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
|
||||
__tournamentEventSourceCount: () => sources.filter((source) => !source.closed).length,
|
||||
__tournamentEventSourceUrls: () => sources.filter((source) => !source.closed).map((source) => source.url),
|
||||
__tournamentEventSourceAuthenticated: () =>
|
||||
sources.filter((source) => !source.closed).every((source) => source.authenticated),
|
||||
__emitTournamentEvent: (type: string, payload: unknown) => {
|
||||
for (const source of FakeEventSource.instances) source.emit(type, payload);
|
||||
for (const source of sources) source.emit(type, payload);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -885,6 +905,18 @@ test('betting realtime refresh is shared across tabs and preserves local interac
|
||||
).flat();
|
||||
expect(activeSourceUrls).toHaveLength(1);
|
||||
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
|
||||
expect(new URL(activeSourceUrls[0]!).searchParams.has('token')).toBe(false);
|
||||
for (const candidate of [page, follower]) {
|
||||
expect(
|
||||
await candidate.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
__tournamentEventSourceAuthenticated: () => boolean;
|
||||
}
|
||||
).__tournamentEventSourceAuthenticated()
|
||||
)
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const before = {
|
||||
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"chart.js": "4.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"es-toolkit": "^1.43.0",
|
||||
"eventsource": "5.1.2",
|
||||
"mitt": "^3.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"tippy.js": "6.3.7",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
|
||||
import { receiveClockEngineState } from '../composables/useClockDisplay';
|
||||
import { computed, ref, toRaw, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
@@ -1139,16 +1140,15 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
};
|
||||
|
||||
let realtimeSource: EventSource | null = null;
|
||||
let realtimeSource: AuthenticatedEventSource | null = null;
|
||||
let realtimeToken: string | null = null;
|
||||
let visibilityListenerInstalled = false;
|
||||
|
||||
const isAccessToken = (token: string | null): boolean => Boolean(token?.startsWith('ga_'));
|
||||
|
||||
const buildRealtimeUrl = (token: string): string => {
|
||||
const buildRealtimeUrl = (): string => {
|
||||
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
@@ -1284,15 +1284,22 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
realtimeToken = token;
|
||||
realtimeStatus.value = 'idle';
|
||||
|
||||
const source = new EventSource(buildRealtimeUrl(token));
|
||||
let needsRecovery = false;
|
||||
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
|
||||
realtimeSource = source;
|
||||
|
||||
source.addEventListener('open', () => {
|
||||
markGameServerContact();
|
||||
realtimeStatus.value = 'connected';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
|
||||
if (needsRecovery) {
|
||||
needsRecovery = false;
|
||||
realtimeRefreshQueue.request();
|
||||
}
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
source.addEventListener('error', (event) => {
|
||||
if (event.code === 401) void session.refreshGeneralStatus();
|
||||
needsRecovery = true;
|
||||
realtimeStatus.value = realtimeEnabled.value ? 'idle' : 'paused';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createAuthenticatedEventSource, type AuthenticatedEventSource } from '../utils/authenticatedEventSource';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { PublicRealtimeEvent, TournamentViewInvalidation } from '@sammo-ts/common';
|
||||
@@ -49,7 +50,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
const realtimeStatus = ref<'idle' | 'connected'>('idle');
|
||||
|
||||
let activeConsumers = 0;
|
||||
let realtimeSource: EventSource | null = null;
|
||||
let realtimeSource: AuthenticatedEventSource | null = null;
|
||||
let realtimeToken: string | null = null;
|
||||
let realtimeCoordinator: BroadcastTabCoordinator<TournamentTabMessage> | null = null;
|
||||
let realtimeCoordinatorScope: string | null = null;
|
||||
@@ -136,10 +137,9 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
if (!(await session.exchangeGatewayToken())) return null;
|
||||
return session.gameToken && isAccessToken(session.gameToken) ? session.gameToken : null;
|
||||
};
|
||||
const buildRealtimeUrl = (token: string): string => {
|
||||
const buildRealtimeUrl = (): string => {
|
||||
const base = gameFrontendRuntimeConfig.gameSseUrl;
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set('token', token);
|
||||
url.searchParams.set('scope', 'tournament');
|
||||
return url.toString();
|
||||
};
|
||||
@@ -181,7 +181,7 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
if (realtimeSource && realtimeToken === token) return;
|
||||
closeRealtimeSource();
|
||||
realtimeToken = token;
|
||||
const source = new EventSource(buildRealtimeUrl(token));
|
||||
const source = createAuthenticatedEventSource(buildRealtimeUrl(), token);
|
||||
realtimeSource = source;
|
||||
source.addEventListener('open', () => {
|
||||
realtimeStatus.value = 'connected';
|
||||
@@ -196,7 +196,8 @@ export const useTournamentPagesStore = defineStore('tournamentPages', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
source.addEventListener('error', () => {
|
||||
source.addEventListener('error', (event) => {
|
||||
if (event.code === 401) void session.refreshGeneralStatus();
|
||||
needsRecovery = true;
|
||||
realtimeStatus.value = 'idle';
|
||||
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'idle' });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { EventSource } from 'eventsource';
|
||||
|
||||
export type AuthenticatedEventSource = EventSource;
|
||||
|
||||
/** Keep the access token out of browser/proxy URLs and ambient cookies. */
|
||||
export const createAuthenticatedEventSource = (url: string, accessToken: string): EventSource => {
|
||||
const destination = new URL(url);
|
||||
if (destination.searchParams.has('token') || destination.username || destination.password) {
|
||||
throw new Error('Realtime credentials must use the authorization header.');
|
||||
}
|
||||
return new EventSource(destination, {
|
||||
maxBufferSize: 64 * 1024,
|
||||
fetch: (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import test from 'node:test';
|
||||
import { createAuthenticatedEventSource } from '../src/utils/authenticatedEventSource.ts';
|
||||
|
||||
const listen = (server: Server): Promise<string> =>
|
||||
new Promise((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Missing fixture listener');
|
||||
resolve(`http://127.0.0.1:${address.port}`);
|
||||
});
|
||||
});
|
||||
const stop = (server: Server): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
void test(
|
||||
'reconnects with header auth, omits ambient credentials and preserves named UTF-8 SSE events',
|
||||
{ timeout: 5_000 },
|
||||
async () => {
|
||||
const requests: Array<{ url: string; authorization: string | undefined; cookie: string | undefined }> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push({
|
||||
url: request.url!,
|
||||
authorization: request.headers.authorization,
|
||||
cookie: request.headers.cookie,
|
||||
});
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
response.write(
|
||||
`retry: 20\nevent: update\ndata: ${JSON.stringify({ value: '갱신', attempt: requests.length })}\n\n`
|
||||
);
|
||||
if (requests.length === 1) response.end();
|
||||
});
|
||||
const origin = await listen(server);
|
||||
const source = createAuthenticatedEventSource(`${origin}/events?scope=tournament`, 'ga_synthetic-fixture');
|
||||
try {
|
||||
const events: unknown[] = [];
|
||||
await new Promise<void>((resolve) =>
|
||||
source.addEventListener('update', (event) => {
|
||||
events.push(JSON.parse(event.data));
|
||||
if (events.length === 2) resolve();
|
||||
})
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{ value: '갱신', attempt: 1 },
|
||||
{ value: '갱신', attempt: 2 },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
requests,
|
||||
Array.from({ length: 2 }, () => ({
|
||||
url: '/events?scope=tournament',
|
||||
authorization: 'Bearer ga_synthetic-fixture',
|
||||
cookie: undefined,
|
||||
}))
|
||||
);
|
||||
} finally {
|
||||
source.close();
|
||||
await stop(server);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
void test('401 is terminal and redirects never forward the authorization header', { timeout: 5_000 }, async () => {
|
||||
let redirectedRequests = 0;
|
||||
const target = createServer((_req, res) => {
|
||||
redirectedRequests += 1;
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
});
|
||||
const targetOrigin = await listen(target);
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(req.url === '/unauthorized' ? 401 : 302, { location: targetOrigin });
|
||||
res.end();
|
||||
});
|
||||
const origin = await listen(server);
|
||||
try {
|
||||
for (const endpoint of ['unauthorized', 'redirect']) {
|
||||
const source = createAuthenticatedEventSource(`${origin}/${endpoint}`, 'ga_synthetic-fixture');
|
||||
try {
|
||||
const code = await new Promise<number | undefined>((resolve) =>
|
||||
source.addEventListener('error', (event) => resolve(event.code))
|
||||
);
|
||||
if (endpoint === 'unauthorized') {
|
||||
assert.equal(code, 401);
|
||||
assert.equal(source.readyState, source.CLOSED);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
assert.equal(redirectedRequests, 0);
|
||||
} finally {
|
||||
await stop(server);
|
||||
await stop(target);
|
||||
}
|
||||
});
|
||||
|
||||
void test('rejects configured query or URL credentials before connecting', () => {
|
||||
assert.throws(() => createAuthenticatedEventSource('http://localhost/events?token=synthetic', 'ga_synthetic'));
|
||||
assert.throws(() => createAuthenticatedEventSource('http://user:synthetic@localhost/events', 'ga_synthetic'));
|
||||
});
|
||||
Reference in New Issue
Block a user