Improve networking
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import type { ServerAddress, Welcome } from "./handshake.ts";
|
||||
|
||||
// asks before running a server's mods when the page came from somewhere else, see "Security" in MODS.md.
|
||||
// resolves with whether the player wants to join
|
||||
export function confirm_mods(address: ServerAddress, welcome: Welcome): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.cssText =
|
||||
"position:fixed;inset:0;display:flex;align-items:center;justify-content:center;padding:16px;" +
|
||||
"background:rgba(0,0,0,0.85);color:white;font:16px system-ui,sans-serif;";
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.style.cssText = "max-width:480px;display:flex;flex-direction:column;gap:12px;";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.style.cssText = "font-size:20px;";
|
||||
title.textContent = `Join ${address.base.host}?`;
|
||||
|
||||
const warning = document.createElement("div");
|
||||
warning.style.cssText = "opacity:0.8;";
|
||||
warning.textContent = "This server runs these mods in your browser. They can do anything this page can, " +
|
||||
"including reading what it saved. Only join servers you trust.";
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.style.cssText = "margin:0;padding-left:20px;";
|
||||
for (const mod of welcome.mods) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = `${mod.name} ${mod.version} (${mod.id})`;
|
||||
list.append(item);
|
||||
}
|
||||
|
||||
const buttons = document.createElement("div");
|
||||
buttons.style.cssText = "display:flex;gap:8px;justify-content:flex-end;";
|
||||
const button = (label: string, answer: boolean) => {
|
||||
const element = document.createElement("button");
|
||||
element.textContent = label;
|
||||
element.style.cssText = "font:inherit;padding:6px 16px;cursor:pointer;";
|
||||
element.addEventListener("click", () => {
|
||||
overlay.remove();
|
||||
resolve(answer);
|
||||
});
|
||||
return element;
|
||||
};
|
||||
buttons.append(button("Cancel", false), button("Join", true));
|
||||
|
||||
panel.append(title, warning, list, buttons);
|
||||
overlay.append(panel);
|
||||
document.body.append(overlay);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// connecting to a server and getting what it needs before joining, see "Delivery to clients" in MODS.md:
|
||||
// hello -> welcome, download and check everything, ready -> join
|
||||
import type { AtlasListing } from "$/common/mod_loader.ts";
|
||||
import { ClientMessage, PROTOCOL_VERSION, ServerMessage } from "$/common/protocol.ts";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 5000;
|
||||
const TRUST_KEY_PREFIX = "bworld:trusted:";
|
||||
|
||||
export type Welcome = Extract<ServerMessage, { type: "welcome" }>;
|
||||
export type Join = Extract<ServerMessage, { type: "join" }>;
|
||||
|
||||
export interface ServerAddress {
|
||||
ws_url: string;
|
||||
// where the server's files are, mod paths are relative to this
|
||||
base: URL;
|
||||
// the page came from somewhere else than the server, so its mods need the player's ok
|
||||
cross_origin: boolean;
|
||||
}
|
||||
|
||||
// something went wrong in a way the player should see
|
||||
export class HandshakeError extends Error {}
|
||||
|
||||
export function server_address(page = new URL(location.href)): ServerAddress {
|
||||
// ?server=host:port, otherwise the server that served the page
|
||||
const host = page.searchParams.get("server") ?? page.host;
|
||||
const secure = page.protocol === "https:";
|
||||
const base = new URL(`${secure ? "https" : "http"}://${host}/`);
|
||||
return {
|
||||
ws_url: `${secure ? "wss" : "ws"}://${host}/ws`,
|
||||
base,
|
||||
cross_origin: base.origin !== page.origin,
|
||||
};
|
||||
}
|
||||
|
||||
// a socket whose messages all land in one queue, so none get lost between the handshake and the game
|
||||
export class ServerSocket {
|
||||
socket: WebSocket;
|
||||
messages: ServerMessage[] = [];
|
||||
closed = false;
|
||||
#waiters: (() => void)[] = [];
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this.socket = socket;
|
||||
socket.addEventListener("message", (event) => {
|
||||
this.messages.push(JSON.parse(event.data));
|
||||
this.#wake();
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
this.#wake();
|
||||
});
|
||||
}
|
||||
|
||||
send(message: ClientMessage) {
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
|
||||
// takes the first message of one of these types out of the queue, waiting for it if needed
|
||||
async next<T extends ServerMessage["type"]>(...types: T[]): Promise<Extract<ServerMessage, { type: T }>> {
|
||||
while (true) {
|
||||
const index = this.messages.findIndex((m) => (types as string[]).includes(m.type));
|
||||
if (index !== -1) {
|
||||
return this.messages.splice(index, 1)[0] as Extract<ServerMessage, { type: T }>;
|
||||
}
|
||||
if (this.closed) {
|
||||
throw new HandshakeError("The server closed the connection");
|
||||
}
|
||||
await new Promise<void>((resolve) => this.#waiters.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
#wake() {
|
||||
for (const waiter of this.#waiters.splice(0)) waiter();
|
||||
}
|
||||
}
|
||||
|
||||
export async function connect(
|
||||
address: ServerAddress,
|
||||
name: string,
|
||||
): Promise<{ socket: ServerSocket; welcome: Welcome }> {
|
||||
const socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(address.ws_url);
|
||||
} catch {
|
||||
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
reject(new HandshakeError(`The server at ${address.ws_url} didn't answer`));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(ws);
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new HandshakeError(`Couldn't connect to the server at ${address.ws_url}`));
|
||||
});
|
||||
});
|
||||
|
||||
const server = new ServerSocket(socket);
|
||||
server.send({ type: "hello", name, protocol: PROTOCOL_VERSION });
|
||||
const answer = await server.next("welcome", "rejected");
|
||||
if (answer.type === "rejected") {
|
||||
server.close();
|
||||
throw new HandshakeError(answer.reason);
|
||||
}
|
||||
return { socket: server, welcome: answer };
|
||||
}
|
||||
|
||||
// tell the server everything's loaded and wait to be let in
|
||||
export async function join(socket: ServerSocket): Promise<Join> {
|
||||
socket.send({ type: "ready" });
|
||||
const answer = await socket.next("join", "rejected");
|
||||
if (answer.type === "rejected") {
|
||||
socket.close();
|
||||
throw new HandshakeError(answer.reason);
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
// downloads a file and checks it's the one the server listed. use the bytes returned, never fetch it again
|
||||
export async function fetch_verified(url: URL, sha256: string, what: string): Promise<Uint8Array<ArrayBuffer>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url);
|
||||
} catch {
|
||||
throw new HandshakeError(`Couldn't download ${what} from ${url}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new HandshakeError(`Couldn't download ${what} (${response.status})`);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
if (digest !== sha256) {
|
||||
throw new HandshakeError(`${what} doesn't match what the server listed`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// a url for code that was already downloaded and checked, so importing it can't fetch something else
|
||||
export function code_url(bytes: Uint8Array<ArrayBuffer>): string {
|
||||
return URL.createObjectURL(new Blob([bytes], { type: "text/javascript" }));
|
||||
}
|
||||
|
||||
export async function load_atlas(address: ServerAddress, atlas: AtlasListing) {
|
||||
const [png, json] = await Promise.all([
|
||||
fetch_verified(new URL(atlas.png, address.base), atlas.sha256.png, "the texture atlas"),
|
||||
fetch_verified(new URL(atlas.json, address.base), atlas.sha256.json, "the texture atlas"),
|
||||
]);
|
||||
return {
|
||||
image: await createImageBitmap(new Blob([png], { type: "image/png" })),
|
||||
regions: JSON.parse(new TextDecoder().decode(json)) as Record<string, { x: number; y: number }>,
|
||||
};
|
||||
}
|
||||
|
||||
// players ok a cross-origin server's mods once, per server and exact mod versions
|
||||
|
||||
function trust_key(address: ServerAddress) {
|
||||
return TRUST_KEY_PREFIX + address.base.origin;
|
||||
}
|
||||
|
||||
function mod_versions(welcome: Welcome) {
|
||||
return welcome.mods.map((mod) => `${mod.id}@${mod.hash}`).sort().join(",");
|
||||
}
|
||||
|
||||
export function is_trusted(address: ServerAddress, welcome: Welcome): boolean {
|
||||
try {
|
||||
return localStorage.getItem(trust_key(address)) === mod_versions(welcome);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function remember_trust(address: ServerAddress, welcome: Welcome) {
|
||||
try {
|
||||
localStorage.setItem(trust_key(address), mod_versions(welcome));
|
||||
} catch {
|
||||
// private windows and blocked storage just ask again next time
|
||||
}
|
||||
}
|
||||
+39
-20
@@ -1,8 +1,11 @@
|
||||
import { AssetManager } from "./assets.ts";
|
||||
import { ClientWorld } from "./client_world.ts";
|
||||
import { InputManager } from "./input_manager.ts";
|
||||
import { Connection, get_player_name, get_server_url } from "./network.ts";
|
||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window } from "./renderer/mod.ts";
|
||||
import { Connection, get_player_name } from "./network.ts";
|
||||
import { connect, HandshakeError, is_trusted, join, load_atlas, remember_trust, server_address } from "./handshake.ts";
|
||||
import { confirm_mods } from "./confirm_mods.ts";
|
||||
import { ModLoadError } from "$/common/mod_loader.ts";
|
||||
import { begin_drawing, clear_background, end_drawing, init_font, init_window, load_texture } from "./renderer/mod.ts";
|
||||
import { is_stopped, show_fatal_error } from "./fatal.ts";
|
||||
import { load_client_mods, set_mods_world } from "./mods.ts";
|
||||
|
||||
@@ -76,9 +79,6 @@ InputManager.initialize(canvas);
|
||||
|
||||
AssetManager.instance.load("bworld:assets_text", "/assets/ASSETS.md");
|
||||
|
||||
AssetManager.instance.load("bworld:textures", "/assets/sprites/textures.png");
|
||||
AssetManager.instance.load("bworld:textures_info", "/assets/sprites/textures.json");
|
||||
|
||||
AssetManager.instance.load("bworld:player", "/assets/sprites/player.png");
|
||||
AssetManager.instance.load("bworld:roguelike", "/assets/sprites/roguelike.png");
|
||||
AssetManager.instance.load("bworld:ui", "/assets/sprites/ui.png");
|
||||
@@ -90,24 +90,37 @@ await AssetManager.instance.load_all();
|
||||
|
||||
init_font();
|
||||
|
||||
// the game only runs against a server, it owns the world and everything in it
|
||||
const connection = await Connection.open(get_server_url(), get_player_name());
|
||||
let mods_loaded = false;
|
||||
if (connection) {
|
||||
console.log(`Connected to ${get_server_url()}`);
|
||||
// the game only runs against a server, it owns the world and everything in it. see "Delivery to clients" in MODS.md
|
||||
async function join_server(): Promise<Connection> {
|
||||
const address = server_address();
|
||||
const { socket, welcome } = await connect(address, get_player_name());
|
||||
console.log(`Connected to ${address.ws_url}`);
|
||||
|
||||
try {
|
||||
// blocks, items and textures all come from mods, so this happens before anything else
|
||||
await load_client_mods(connection.mods);
|
||||
console.log(`Mods: ${connection.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
mods_loaded = true;
|
||||
if (address.cross_origin && !is_trusted(address, welcome)) {
|
||||
if (!await confirm_mods(address, welcome)) {
|
||||
throw new HandshakeError(`You didn't join ${address.base.host}`);
|
||||
}
|
||||
remember_trust(address, welcome);
|
||||
}
|
||||
|
||||
// textures, blocks and items all come from the server's mods, so this happens before anything else
|
||||
const atlas = await load_atlas(address, welcome.atlas);
|
||||
AssetManager.instance.assets["bworld:textures"] = load_texture(atlas.image);
|
||||
AssetManager.instance.assets["bworld:textures_info"] = atlas.regions;
|
||||
await load_client_mods(welcome.mods, address.base);
|
||||
console.log(`Mods: ${welcome.mods.map((mod) => `${mod.id} ${mod.version}`).join(", ") || "none"}`);
|
||||
|
||||
const joined = await join(socket);
|
||||
return new Connection(socket, welcome, joined);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_fatal_error(`Couldn't load the server's mods: ${e instanceof Error ? e.message : e}`);
|
||||
connection.socket.close();
|
||||
socket.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (connection && mods_loaded) {
|
||||
try {
|
||||
const connection = await join_server();
|
||||
const client_world = new ClientWorld(connection);
|
||||
set_mods_world(client_world);
|
||||
client_world.add_chat("Connected to the server");
|
||||
@@ -116,6 +129,12 @@ if (connection && mods_loaded) {
|
||||
loop.start();
|
||||
|
||||
console.log("Game started");
|
||||
} else if (!connection) {
|
||||
show_fatal_error(`Couldn't connect to the server at ${get_server_url()}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const message = e instanceof HandshakeError
|
||||
? e.message
|
||||
: e instanceof ModLoadError
|
||||
? `Couldn't load the server's mods: ${e.message}`
|
||||
: `Something went wrong joining the server: ${e instanceof Error ? e.message : e}`;
|
||||
show_fatal_error(message);
|
||||
}
|
||||
|
||||
+26
-15
@@ -8,6 +8,7 @@ import type { OreJson } from "$/common/mod_data.ts";
|
||||
import { AIR_ID } from "$/common/protocol.ts";
|
||||
import { Position } from "$/common/components/position.ts";
|
||||
import type { ClientWorld } from "./client_world.ts";
|
||||
import { code_url, fetch_verified } from "./handshake.ts";
|
||||
|
||||
// what the chunk workers need to generate the same world as the server
|
||||
export const worldgen_mods: { scripts: { mod: string; url: string }[]; ores: OreJson[] } = { scripts: [], ores: [] };
|
||||
@@ -19,26 +20,36 @@ export function set_mods_world(client_world: ClientWorld) {
|
||||
world = client_world;
|
||||
}
|
||||
|
||||
export async function load_client_mods(listings: ModListing[]) {
|
||||
const site = new URL("/", location.href);
|
||||
|
||||
const datas = await Promise.all(listings.map(async (listing) => {
|
||||
const response = await fetch(new URL(listing.data, site));
|
||||
if (!response.ok) {
|
||||
throw new ModLoadError(listing.id, `couldn't download its data (${response.status})`);
|
||||
}
|
||||
return { id: listing.id, data: await response.json() as ModData };
|
||||
// downloads every mod's files and checks them against the hashes the server listed, then registers the data and
|
||||
// runs the client scripts. everything a mod runs is imported from the checked bytes, never fetched twice
|
||||
export async function load_client_mods(listings: ModListing[], base: URL) {
|
||||
const downloads = await Promise.all(listings.map(async (listing) => {
|
||||
const get = async (path: string | undefined, sha256: string | undefined, what: string) => {
|
||||
if (!path) return undefined;
|
||||
try {
|
||||
return await fetch_verified(new URL(path, base), sha256 ?? "", what);
|
||||
} catch (e) {
|
||||
throw new ModLoadError(listing.id, (e as Error).message);
|
||||
}
|
||||
};
|
||||
const [data, client, worldgen] = await Promise.all([
|
||||
get(listing.data, listing.sha256.data, "its data"),
|
||||
get(listing.client, listing.sha256.client, "its client script"),
|
||||
get(listing.worldgen, listing.sha256.worldgen, "its worldgen script"),
|
||||
]);
|
||||
return { listing, data: JSON.parse(new TextDecoder().decode(data)) as ModData, client, worldgen };
|
||||
}));
|
||||
const recipes = register_mod_data(datas);
|
||||
|
||||
const recipes = register_mod_data(downloads.map(({ listing, data }) => ({ id: listing.id, data })));
|
||||
|
||||
worldgen_mods.ores = recipes.ores;
|
||||
worldgen_mods.scripts = listings.flatMap((listing) =>
|
||||
listing.worldgen ? [{ mod: listing.id, url: new URL(listing.worldgen, site).href }] : []
|
||||
worldgen_mods.scripts = downloads.flatMap(({ listing, worldgen }) =>
|
||||
worldgen ? [{ mod: listing.id, url: code_url(worldgen) }] : []
|
||||
);
|
||||
|
||||
for (const listing of listings) {
|
||||
if (!listing.client) continue;
|
||||
const module = await import(new URL(listing.client, site).href);
|
||||
for (const { listing, client } of downloads) {
|
||||
if (!client) continue;
|
||||
const module = await import(code_url(client));
|
||||
if (typeof module.setup !== "function") {
|
||||
throw new ModLoadError(listing.id, "the client script doesn't export a setup function");
|
||||
}
|
||||
|
||||
+19
-65
@@ -1,7 +1,6 @@
|
||||
import { BlockChange, ClientMessage, PlayerInfo, ServerMessage } from "$/common/protocol.ts";
|
||||
import type { ModListing } from "$/common/mod_loader.ts";
|
||||
|
||||
const CONNECT_TIMEOUT_MS = 3000;
|
||||
import type { Join, ServerSocket, Welcome } from "./handshake.ts";
|
||||
|
||||
export interface RemotePlayer extends PlayerInfo {
|
||||
// where we draw them, eased towards x/y/z so movement isnt choppy
|
||||
@@ -12,7 +11,7 @@ export interface RemotePlayer extends PlayerInfo {
|
||||
}
|
||||
|
||||
export class Connection {
|
||||
socket: WebSocket;
|
||||
#server: ServerSocket;
|
||||
id: string;
|
||||
name: string;
|
||||
seed: string;
|
||||
@@ -22,35 +21,31 @@ export class Connection {
|
||||
selected_slot: number;
|
||||
players = new Map<string, RemotePlayer>();
|
||||
|
||||
// handled by the network system inside the game loop, not whenever the socket feels like it
|
||||
incoming: ServerMessage[] = [];
|
||||
closed = false;
|
||||
|
||||
constructor(socket: WebSocket, welcome: Extract<ServerMessage, { type: "welcome" }>) {
|
||||
this.socket = socket;
|
||||
this.id = welcome.id;
|
||||
this.name = welcome.name;
|
||||
constructor(server: ServerSocket, welcome: Welcome, join: Join) {
|
||||
this.#server = server;
|
||||
this.id = join.id;
|
||||
this.name = join.name;
|
||||
this.seed = welcome.seed;
|
||||
this.mods = welcome.mods;
|
||||
this.initial_changes = welcome.changes;
|
||||
this.spawn = welcome.spawn;
|
||||
this.selected_slot = welcome.selected_slot;
|
||||
for (const player of welcome.players) {
|
||||
this.initial_changes = join.changes;
|
||||
this.spawn = join.spawn;
|
||||
this.selected_slot = join.selected_slot;
|
||||
for (const player of join.players) {
|
||||
this.add_player(player);
|
||||
}
|
||||
}
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
this.incoming.push(JSON.parse(event.data));
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
});
|
||||
// handled by the network system inside the game loop, not whenever the socket feels like it
|
||||
get incoming(): ServerMessage[] {
|
||||
return this.#server.messages;
|
||||
}
|
||||
|
||||
get closed() {
|
||||
return this.#server.closed;
|
||||
}
|
||||
|
||||
send(message: ClientMessage) {
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
this.#server.send(message);
|
||||
}
|
||||
|
||||
add_player(player: PlayerInfo) {
|
||||
@@ -62,40 +57,6 @@ export class Connection {
|
||||
color: color_from_name(player.name),
|
||||
});
|
||||
}
|
||||
|
||||
static open(url: string, name: string): Promise<Connection | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close();
|
||||
resolve(undefined);
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
socket.send(JSON.stringify({ type: "hello", name } satisfies ClientMessage));
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message: ServerMessage = JSON.parse(event.data);
|
||||
if (message.type === "welcome") {
|
||||
clearTimeout(timeout);
|
||||
resolve(new Connection(socket, message));
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function color_from_name(name: string): [number, number, number] {
|
||||
@@ -122,13 +83,6 @@ function color_from_name(name: string): [number, number, number] {
|
||||
return [r + m, g + m, b + m];
|
||||
}
|
||||
|
||||
export function get_server_url(): string {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const server = params.get("server");
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${server ?? location.host}/ws`;
|
||||
}
|
||||
|
||||
export function get_player_name(): string {
|
||||
return new URLSearchParams(location.search).get("name") ?? "";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "./core.ts";
|
||||
import { Texture } from "./types.ts";
|
||||
|
||||
export function load_texture(image: HTMLImageElement): Texture {
|
||||
export function load_texture(image: HTMLImageElement | ImageBitmap): Texture {
|
||||
const texture = create_texture(image.width, image.height);
|
||||
device.queue.copyExternalImageToTexture({ source: image }, { texture }, [image.width, image.height]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user