mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(guild): import all template channel types, fetch on desktop (#3473)
This commit is contained in:
1 parent
00249f9b4d
commit
99be186fa1
50 files changed
+2008
-111
No files matched your search
@@ -59,6 +59,7 @@ import type {DesktopCapabilityManifest} from '@fluxer/desktop_ipc/src/Capability
|
||||
import type {NativeGatewayTransportAPI} from '@fluxer/desktop_ipc/src/GatewayTransportContract';
|
||||
import type {DesktopKnownInstanceStorageAPI} from '@fluxer/desktop_ipc/src/KnownInstanceContract';
|
||||
import type {DesktopModuleAPI, DesktopUpdateAPI} from '@fluxer/desktop_ipc/src/ModuleContract';
|
||||
import type {OtherPlatformTemplateAPI} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
|
||||
import type {DesktopStorageAPI} from '@fluxer/desktop_ipc/src/StorageContract';
|
||||
import type {VoiceEngineV2BridgeHardwareEncoderApi} from '@fluxer/voice_engine_v2/bridge';
|
||||
import type {
|
||||
@@ -384,6 +385,7 @@ export interface ElectronAPI {
|
||||
desktopKnownInstances: DesktopKnownInstanceStorageAPI;
|
||||
desktopHandoff: DesktopHandoffAPI;
|
||||
nativeGatewayTransport: NativeGatewayTransportAPI;
|
||||
otherPlatformTemplate: OtherPlatformTemplateAPI;
|
||||
}
|
||||
|
||||
export interface GlobalShortcutsPortalStatus {
|
||||
|
||||
@@ -49,6 +49,10 @@ import {
|
||||
} from '@electron/main/NativeGatewayTransport';
|
||||
import {registerNotificationIpcHandlers} from '@electron/main/NotificationsIpc';
|
||||
import {openExternalDeduped} from '@electron/main/OpenExternal';
|
||||
import {
|
||||
cleanupOtherPlatformTemplateHandlers,
|
||||
registerOtherPlatformTemplateHandlers,
|
||||
} from '@electron/main/OtherPlatformTemplateIpc';
|
||||
import {registerPasskeyHandlers} from '@electron/main/Passkeys';
|
||||
import {getAppMetricsSnapshot, getDesktopInfo, getGpuInfo} from '@electron/main/PlatformInfo';
|
||||
import {
|
||||
@@ -487,6 +491,7 @@ export function registerIpcHandlers(): void {
|
||||
registerPasskeyHandlers();
|
||||
registerLinuxAppearanceHandlers();
|
||||
registerBrowserHandoffHandlers();
|
||||
registerOtherPlatformTemplateHandlers();
|
||||
}
|
||||
|
||||
let linuxAppearanceSubscription: LinuxAppearanceSubscription | null = null;
|
||||
@@ -570,6 +575,7 @@ export function cleanupIpcHandlers(_options: {quitting?: boolean} = {}): void {
|
||||
browserHandoff?.cleanup();
|
||||
browserHandoff = null;
|
||||
setHandoffReturnLinkSink(null);
|
||||
cleanupOtherPlatformTemplateHandlers();
|
||||
if (linuxAppearanceSubscription) {
|
||||
try {
|
||||
linuxAppearanceSubscription.close();
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {afterEach, describe, test} from 'node:test';
|
||||
import {installElectronStub, installWindowStub} from './LocalAppTestSupport.test.mjs';
|
||||
|
||||
installElectronStub();
|
||||
installWindowStub(null);
|
||||
|
||||
const {DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS, OtherPlatformTemplateFetchFailure, isOtherPlatformTemplateCode} =
|
||||
await import('../../../packages/desktop_ipc/src/OtherPlatformTemplateContract.ts');
|
||||
const {
|
||||
MAX_TEMPLATE_RESPONSE_BYTES,
|
||||
cleanupOtherPlatformTemplateHandlers,
|
||||
fetchOtherPlatformTemplate,
|
||||
registerOtherPlatformTemplateHandlers,
|
||||
} = await import('./OtherPlatformTemplateIpc.ts');
|
||||
const {createOtherPlatformTemplatePreloadAPI} = await import('../preload/OtherPlatformTemplatePreload.ts');
|
||||
|
||||
const TEMPLATE_BODY = {code: 'hgM48av5Q69A', name: 'Starter', serialized_source_guild: {name: 'Starter'}};
|
||||
|
||||
function installFetch(respond) {
|
||||
const calls = [];
|
||||
const handlers = new Map();
|
||||
installElectronStub({
|
||||
net: {
|
||||
fetch: async (url, init) => {
|
||||
calls.push({url, init});
|
||||
return respond(url, init);
|
||||
},
|
||||
},
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => handlers.set(channel, listener),
|
||||
removeHandler: (channel) => handlers.delete(channel),
|
||||
},
|
||||
});
|
||||
return {calls, handlers};
|
||||
}
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function topLevelSender() {
|
||||
const frame = {detached: false, parent: null, url: 'fluxer-app://app/'};
|
||||
return {sender: {}, senderFrame: frame};
|
||||
}
|
||||
|
||||
function subframeSender() {
|
||||
const parent = {detached: false, parent: null, url: 'fluxer-app://app/'};
|
||||
return {sender: {}, senderFrame: {detached: false, parent, url: 'fluxer-app://app/'}};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanupOtherPlatformTemplateHandlers();
|
||||
});
|
||||
|
||||
describe('template code validation', () => {
|
||||
test('accepts alphanumeric codes and rejects anything that could reshape the URL', () => {
|
||||
assert.equal(isOtherPlatformTemplateCode('hgM48av5Q69A'), true);
|
||||
assert.equal(isOtherPlatformTemplateCode('a'.repeat(64)), true);
|
||||
for (const value of [
|
||||
'',
|
||||
'a'.repeat(65),
|
||||
'abc/def',
|
||||
'../users/@me',
|
||||
'abc?x=1',
|
||||
'abc#frag',
|
||||
'abc def',
|
||||
'abc%2F',
|
||||
'https://discord.new/abc',
|
||||
42,
|
||||
null,
|
||||
undefined,
|
||||
]) {
|
||||
assert.equal(isOtherPlatformTemplateCode(value), false, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an invalid code before any request is made', async () => {
|
||||
const {calls} = installFetch(() => jsonResponse(TEMPLATE_BODY));
|
||||
for (const code of ['abc/../x', '', 'a'.repeat(65), {code: 'abc'}]) {
|
||||
const result = await fetchOtherPlatformTemplate(code);
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
failure: OtherPlatformTemplateFetchFailure.INVALID_CODE,
|
||||
});
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template fetch', () => {
|
||||
test('requests only the fixed template endpoint with a generic browser user agent and no credentials', async () => {
|
||||
const {calls} = installFetch(() => jsonResponse(TEMPLATE_BODY));
|
||||
const result = await fetchOtherPlatformTemplate('hgM48av5Q69A');
|
||||
assert.deepEqual(result, {ok: true, template: TEMPLATE_BODY});
|
||||
assert.equal(calls.length, 1);
|
||||
const [{url, init}] = calls;
|
||||
assert.equal(url, 'https://discord.com/api/v10/guilds/templates/hgM48av5Q69A');
|
||||
assert.equal(init.method, 'GET');
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.credentials, 'omit');
|
||||
const userAgent = init.headers['User-Agent'];
|
||||
assert.match(userAgent, /^Mozilla\/5\.0 \(/);
|
||||
assert.doesNotMatch(userAgent, /fluxer|electron/i);
|
||||
assert.equal(init.headers.Accept, 'application/json');
|
||||
assert.ok(init.signal instanceof AbortSignal);
|
||||
});
|
||||
|
||||
test('maps a 404 to not found', async () => {
|
||||
installFetch(() => jsonResponse({message: 'Unknown Guild Template', code: 10057}, {status: 404}));
|
||||
assert.deepEqual(await fetchOtherPlatformTemplate('missing'), {
|
||||
ok: false,
|
||||
failure: OtherPlatformTemplateFetchFailure.NOT_FOUND,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps a 429 to rate limited', async () => {
|
||||
installFetch(() =>
|
||||
jsonResponse({message: 'You are being rate limited.'}, {status: 429, headers: {'retry-after': '2.4'}}),
|
||||
);
|
||||
assert.deepEqual(await fetchOtherPlatformTemplate('busy'), {
|
||||
ok: false,
|
||||
failure: OtherPlatformTemplateFetchFailure.RATE_LIMITED,
|
||||
});
|
||||
});
|
||||
|
||||
test('maps server errors and transport failures to network', async () => {
|
||||
installFetch(() => new Response('oops', {status: 503}));
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
|
||||
installFetch(() => {
|
||||
throw new TypeError('net::ERR_INTERNET_DISCONNECTED');
|
||||
});
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
|
||||
installFetch(() => {
|
||||
throw new TypeError('redirect mode is set to error');
|
||||
});
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
|
||||
});
|
||||
|
||||
test('maps unreadable, non-object and oversized bodies to invalid response', async () => {
|
||||
installFetch(() => new Response('<html>not json</html>', {status: 200}));
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
installFetch(() => jsonResponse([TEMPLATE_BODY]));
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
installFetch(
|
||||
() => new Response('x', {status: 200, headers: {'content-length': String(MAX_TEMPLATE_RESPONSE_BYTES + 1)}}),
|
||||
);
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
const chunk = new Uint8Array(1024 * 1024).fill(0x20);
|
||||
let sent = 0;
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent > MAX_TEMPLATE_RESPONSE_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
sent += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
installFetch(() => new Response(stream, {status: 200}));
|
||||
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
assert.ok(sent <= MAX_TEMPLATE_RESPONSE_BYTES + chunk.byteLength);
|
||||
});
|
||||
});
|
||||
|
||||
describe('template IPC', () => {
|
||||
test('the preload call reaches the registered handler for a trusted top-level document', async () => {
|
||||
const {handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
|
||||
registerOtherPlatformTemplateHandlers();
|
||||
const handler = handlers.get(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
|
||||
assert.equal(typeof handler, 'function');
|
||||
const api = createOtherPlatformTemplatePreloadAPI({
|
||||
invoke: (channel, ...args) => handlers.get(channel)(topLevelSender(), ...args),
|
||||
});
|
||||
assert.deepEqual(await api.otherPlatformTemplate.fetch('hgM48av5Q69A'), {ok: true, template: TEMPLATE_BODY});
|
||||
});
|
||||
|
||||
test('refuses subframes and detached frames without fetching', async () => {
|
||||
const {calls, handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
|
||||
registerOtherPlatformTemplateHandlers();
|
||||
const handler = handlers.get(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
|
||||
assert.throws(() => handler(subframeSender(), 'hgM48av5Q69A'), /trusted top-level renderer document/);
|
||||
const detached = topLevelSender();
|
||||
detached.senderFrame.detached = true;
|
||||
assert.throws(() => handler(detached, 'hgM48av5Q69A'), /trusted top-level renderer document/);
|
||||
assert.throws(
|
||||
() => handler({sender: {}, senderFrame: null}, 'hgM48av5Q69A'),
|
||||
/trusted top-level renderer document/,
|
||||
);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('cleanup removes the handler', () => {
|
||||
const {handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
|
||||
registerOtherPlatformTemplateHandlers();
|
||||
cleanupOtherPlatformTemplateHandlers();
|
||||
assert.equal(handlers.has(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments';
|
||||
import {
|
||||
DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS,
|
||||
isOtherPlatformTemplateCode,
|
||||
type OtherPlatformTemplateFetchError,
|
||||
OtherPlatformTemplateFetchFailure,
|
||||
type OtherPlatformTemplateFetchResult,
|
||||
} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
|
||||
import {isRecord} from '@fluxer/record_utils/src/RecordGuards';
|
||||
import {ipcMain, net} from 'electron';
|
||||
|
||||
const logger = createChildLogger('OtherPlatformTemplate');
|
||||
|
||||
const TEMPLATE_ENDPOINT_ORIGIN = 'https://discord.com';
|
||||
const TEMPLATE_ENDPOINT_PATH = '/api/v10/guilds/templates/';
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
export const MAX_TEMPLATE_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
let handlersRegistered = false;
|
||||
|
||||
class ResponseTooLargeError extends Error {
|
||||
public constructor() {
|
||||
super('Template response exceeded the size cap');
|
||||
this.name = 'ResponseTooLargeError';
|
||||
}
|
||||
}
|
||||
|
||||
function browserPlatformToken(): string {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return 'Macintosh; Intel Mac OS X 10_15_7';
|
||||
case 'win32':
|
||||
return 'Windows NT 10.0; Win64; x64';
|
||||
default:
|
||||
return 'X11; Linux x86_64';
|
||||
}
|
||||
}
|
||||
|
||||
export function genericBrowserUserAgent(): string {
|
||||
const chromeMajor = Number.parseInt(process.versions.chrome ?? '', 10);
|
||||
const version = Number.isFinite(chromeMajor) && chromeMajor > 0 ? chromeMajor : 140;
|
||||
return `Mozilla/5.0 (${browserPlatformToken()}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version}.0.0.0 Safari/537.36`;
|
||||
}
|
||||
|
||||
export function otherPlatformTemplateUrl(code: string): string {
|
||||
return `${TEMPLATE_ENDPOINT_ORIGIN}${TEMPLATE_ENDPOINT_PATH}${code}`;
|
||||
}
|
||||
|
||||
function failure(kind: OtherPlatformTemplateFetchError['failure']) {
|
||||
return {ok: false, failure: kind} satisfies OtherPlatformTemplateFetchError;
|
||||
}
|
||||
|
||||
async function readBoundedText(response: Response): Promise<string> {
|
||||
const declaredLength = Number.parseInt(response.headers.get('content-length') ?? '', 10);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_TEMPLATE_RESPONSE_BYTES) {
|
||||
throw new ResponseTooLargeError();
|
||||
}
|
||||
if (response.body === null) return '';
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let received = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > MAX_TEMPLATE_RESPONSE_BYTES) {
|
||||
throw new ResponseTooLargeError();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => undefined);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
export async function fetchOtherPlatformTemplate(code: unknown): Promise<OtherPlatformTemplateFetchResult> {
|
||||
if (!isOtherPlatformTemplateCode(code)) {
|
||||
return failure(OtherPlatformTemplateFetchFailure.INVALID_CODE);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await net.fetch(otherPlatformTemplateUrl(code), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': genericBrowserUserAgent(),
|
||||
},
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
signal: controller.signal,
|
||||
bypassCustomProtocolHandlers: true,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return failure(OtherPlatformTemplateFetchFailure.NOT_FOUND);
|
||||
}
|
||||
if (response.status === 429) {
|
||||
return failure(OtherPlatformTemplateFetchFailure.RATE_LIMITED);
|
||||
}
|
||||
if (!response.ok) {
|
||||
logger.warn('Template endpoint answered with an unexpected status', {status: response.status});
|
||||
return failure(OtherPlatformTemplateFetchFailure.NETWORK);
|
||||
}
|
||||
let template: unknown;
|
||||
try {
|
||||
template = JSON.parse(await readBoundedText(response));
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
logger.warn('Template endpoint returned an unreadable body', error);
|
||||
return failure(OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
}
|
||||
if (!isRecord(template)) {
|
||||
return failure(OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
|
||||
}
|
||||
return {ok: true, template};
|
||||
} catch (error) {
|
||||
logger.warn('Template fetch failed', error);
|
||||
return failure(OtherPlatformTemplateFetchFailure.NETWORK);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerOtherPlatformTemplateHandlers(): void {
|
||||
if (handlersRegistered) return;
|
||||
handlersRegistered = true;
|
||||
ipcMain.handle(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch, (event, code: unknown) => {
|
||||
requirePrivilegedRendererDocumentSender(event, DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
|
||||
return fetchOtherPlatformTemplate(code);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupOtherPlatformTemplateHandlers(): void {
|
||||
if (!handlersRegistered) return;
|
||||
handlersRegistered = false;
|
||||
ipcMain.removeHandler(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
OtherPlatformTemplateAPI,
|
||||
OtherPlatformTemplateFetchResult,
|
||||
} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
|
||||
import {DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
|
||||
|
||||
interface OtherPlatformTemplateInvoker {
|
||||
invoke: (channel: string, ...args: Array<unknown>) => Promise<unknown>;
|
||||
}
|
||||
|
||||
interface OtherPlatformTemplatePreloadAPI {
|
||||
readonly otherPlatformTemplate: OtherPlatformTemplateAPI;
|
||||
}
|
||||
|
||||
export function createOtherPlatformTemplatePreloadAPI(
|
||||
renderer: OtherPlatformTemplateInvoker,
|
||||
): OtherPlatformTemplatePreloadAPI {
|
||||
return Object.freeze({
|
||||
otherPlatformTemplate: Object.freeze<OtherPlatformTemplateAPI>({
|
||||
fetch: (code: string) =>
|
||||
renderer.invoke(
|
||||
DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch,
|
||||
code,
|
||||
) as Promise<OtherPlatformTemplateFetchResult>,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,7 @@ import {createFirstContentPaintSignal} from '@electron/preload/FirstContentPaint
|
||||
import {applyLegacyRawLocalStorage} from '@electron/preload/LegacyRawStorageSeed';
|
||||
import {createLocalAppPreloadAPI} from '@electron/preload/LocalAppPreload';
|
||||
import {createNativeGatewayPreloadAPI} from '@electron/preload/NativeGatewayPreload';
|
||||
import {createOtherPlatformTemplatePreloadAPI} from '@electron/preload/OtherPlatformTemplatePreload';
|
||||
import type {DesktopCapabilityManifest} from '@fluxer/desktop_ipc/src/CapabilityManifest';
|
||||
import {DESKTOP_CAPABILITY_MANIFEST_CHANNEL} from '@fluxer/desktop_ipc/src/CapabilityManifest';
|
||||
import {DESKTOP_LAST_ROUTE_CHANNEL} from '@fluxer/desktop_ipc/src/LastRouteContract';
|
||||
@@ -403,6 +404,7 @@ const api: ElectronAPI = {
|
||||
...createLocalAppPreloadAPI(ipcRenderer),
|
||||
...createBrowserHandoffPreloadAPI(ipcRenderer),
|
||||
...createNativeGatewayPreloadAPI(ipcRenderer),
|
||||
...createOtherPlatformTemplatePreloadAPI(ipcRenderer),
|
||||
reportLastRoute: (routePath: string): void => {
|
||||
ipcRenderer.send(DESKTOP_LAST_ROUTE_CHANNEL, routePath);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user