mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-13 10:49:21 +00:00
🐛 fix: restore plugin auth and proxy connection info
This commit is contained in:
@@ -17,6 +17,10 @@ const operatorResourceSync = new OperatorResourceSync();
|
||||
|
||||
export const userService = new UserService(userRepo);
|
||||
export const serverService = new ServerService(serverRepo, k8sService, operatorResourceSync);
|
||||
export const reverseProxyService = new ReverseProxyService(reverseProxyRepo, operatorResourceSync);
|
||||
export const reverseProxyService = new ReverseProxyService(
|
||||
reverseProxyRepo,
|
||||
k8sService,
|
||||
operatorResourceSync
|
||||
);
|
||||
export const wsService = webSocketService;
|
||||
export { k8sService, operatorResourceSync };
|
||||
|
||||
@@ -13,4 +13,5 @@ export interface IReverseProxyService {
|
||||
setEnvVariable(proxyId: string, key: string, value: string): Promise<void>;
|
||||
getEnvVariables(proxyId: string): Promise<EnvVariable[]>;
|
||||
deleteEnvVariable(proxyId: string, key: string): Promise<void>;
|
||||
getConnectionInfo(proxyId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import type {
|
||||
ReverseProxyRepository,
|
||||
ReverseProxyUpdateInput,
|
||||
} from "../../domain/repositories/reverse-proxy.repository";
|
||||
import type { OperatorResourceSync } from "../../services/operator-resource-sync";
|
||||
import type { K8sService } from "../../services/k8s";
|
||||
import {
|
||||
type OperatorResourceSync,
|
||||
operatorResourceName,
|
||||
} from "../../services/operator-resource-sync";
|
||||
import type { IReverseProxyService } from "../interfaces/reverse-proxy.service.interface";
|
||||
import { BaseCrudService } from "./base-crud.service";
|
||||
|
||||
@@ -29,6 +33,7 @@ export class ReverseProxyService
|
||||
{
|
||||
constructor(
|
||||
reverseProxyRepo: ReverseProxyRepository,
|
||||
private k8sService: K8sService,
|
||||
private operatorResourceSync: OperatorResourceSync
|
||||
) {
|
||||
super(
|
||||
@@ -79,4 +84,10 @@ export class ReverseProxyService
|
||||
await super.deleteEnvVariable(proxyId, key);
|
||||
await this.operatorResourceSync.syncReverseProxyById(proxyId);
|
||||
}
|
||||
|
||||
async getConnectionInfo(proxyId: string) {
|
||||
const proxy = await this.getReverseProxyById(proxyId);
|
||||
const serviceName = `${String(proxy.type).toLowerCase()}-${operatorResourceName(proxyId)}`;
|
||||
return this.k8sService.getServerConnectionInfo(serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authPlugin } from "./middleware/auth-plugin";
|
||||
import { errorHandler } from "./middleware/error-handler";
|
||||
import { bootstrapRoutes } from "./routes/bootstrap";
|
||||
import { k8sRoutes } from "./routes/k8s";
|
||||
import { pluginRoutes } from "./routes/plugin";
|
||||
import { reverseProxyRoutes } from "./routes/reverse-proxy";
|
||||
import { serverRoutes } from "./routes/servers";
|
||||
import { terminalRoutes } from "./routes/terminal";
|
||||
@@ -34,7 +35,13 @@ const app = new Elysia({ adapter: node() })
|
||||
.use(bootstrapRoutes)
|
||||
.use(authPlugin)
|
||||
.group("/api", (app) =>
|
||||
app.use(userRoutes).use(serverRoutes).use(reverseProxyRoutes).use(k8sRoutes).use(terminalRoutes)
|
||||
app
|
||||
.use(userRoutes)
|
||||
.use(serverRoutes)
|
||||
.use(reverseProxyRoutes)
|
||||
.use(pluginRoutes)
|
||||
.use(k8sRoutes)
|
||||
.use(terminalRoutes)
|
||||
);
|
||||
|
||||
export type App = typeof app;
|
||||
|
||||
@@ -12,18 +12,18 @@ eventBus.subscribe(ReverseProxyCreatedEvent, async (event) => {
|
||||
{ proxyId: event.proxyId, proxyType: event.proxyType },
|
||||
"Reverse proxy created event"
|
||||
);
|
||||
wsService.broadcast("create", event.proxyType, event.proxyId);
|
||||
wsService.broadcast("CREATE", event.proxyType, event.proxyId);
|
||||
await operatorResourceSync.syncReverseProxyById(event.proxyId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ReverseProxyUpdatedEvent, async (event) => {
|
||||
logger.info({ proxyId: event.proxyId }, "Reverse proxy updated event");
|
||||
wsService.broadcast("update", "reverse-proxy", event.proxyId);
|
||||
wsService.broadcast("UPDATE", "reverse-proxy", event.proxyId);
|
||||
await operatorResourceSync.syncReverseProxyById(event.proxyId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ReverseProxyDeletedEvent, async (event) => {
|
||||
logger.info({ proxyId: event.proxyId }, "Reverse proxy deleted event");
|
||||
wsService.broadcast("delete", "reverse-proxy", event.proxyId);
|
||||
wsService.broadcast("DELETE", "reverse-proxy", event.proxyId);
|
||||
await operatorResourceSync.deleteReverseProxy(event.proxyId);
|
||||
});
|
||||
|
||||
@@ -9,18 +9,18 @@ import { logger } from "../logger";
|
||||
|
||||
eventBus.subscribe(ServerCreatedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId, serverType: event.serverType }, "Server created event");
|
||||
wsService.broadcast("create", event.serverType, event.serverId);
|
||||
wsService.broadcast("CREATE", event.serverType, event.serverId);
|
||||
await operatorResourceSync.syncServerById(event.serverId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ServerUpdatedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId }, "Server updated event");
|
||||
wsService.broadcast("update", "server", event.serverId);
|
||||
wsService.broadcast("UPDATE", "server", event.serverId);
|
||||
await operatorResourceSync.syncServerById(event.serverId);
|
||||
});
|
||||
|
||||
eventBus.subscribe(ServerDeletedEvent, async (event) => {
|
||||
logger.info({ serverId: event.serverId }, "Server deleted event");
|
||||
wsService.broadcast("delete", "server", event.serverId);
|
||||
wsService.broadcast("DELETE", "server", event.serverId);
|
||||
await operatorResourceSync.deleteServer(event.serverId);
|
||||
});
|
||||
|
||||
+3
-3
@@ -64,12 +64,12 @@ export class PrismaReverseProxyRepository implements ReverseProxyRepository {
|
||||
description: input.description ?? null,
|
||||
external_address: input.external_address,
|
||||
external_port: input.external_port,
|
||||
listen_port: input.listen_port ?? 25577,
|
||||
listen_port: input.listen_port ?? 25565,
|
||||
service_type: input.service_type ?? "LOAD_BALANCER",
|
||||
node_port: input.node_port ?? null,
|
||||
memory: input.memory ?? 512,
|
||||
cpu_request: input.cpu_request ?? "100m",
|
||||
cpu_limit: input.cpu_limit ?? "200m",
|
||||
cpu_request: input.cpu_request ?? "250m",
|
||||
cpu_limit: input.cpu_limit ?? "500m",
|
||||
api_key: token,
|
||||
env_variables: input.env_variables
|
||||
? {
|
||||
|
||||
@@ -67,12 +67,12 @@ export class PrismaServerRepository implements ServerRepository {
|
||||
node_port: input.node_port ?? null,
|
||||
memory: input.memory ?? 2048,
|
||||
memory_request: input.memory_request ?? 1024,
|
||||
cpu_request: input.cpu_request ?? "250m",
|
||||
cpu_limit: input.cpu_limit ?? "500m",
|
||||
jar_type: input.jar_type ?? "PAPER",
|
||||
cpu_request: input.cpu_request ?? "500m",
|
||||
cpu_limit: input.cpu_limit ?? "2",
|
||||
jar_type: input.jar_type ?? "VANILLA",
|
||||
minecraft_version: input.minecraft_version ?? "LATEST",
|
||||
jvm_opts: input.jvm_opts ?? null,
|
||||
use_aikar_flags: input.use_aikar_flags ?? true,
|
||||
use_aikar_flags: input.use_aikar_flags ?? false,
|
||||
use_meowice_flags: input.use_meowice_flags ?? false,
|
||||
difficulty: input.difficulty ?? "EASY",
|
||||
game_mode: input.game_mode ?? "SURVIVAL",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { prisma } from "@minikura/db";
|
||||
|
||||
export async function findApiKeyOwner(apiKey: string) {
|
||||
if (!apiKey) return null;
|
||||
|
||||
const [server, proxy] = await Promise.all([
|
||||
prisma.server.findUnique({ where: { api_key: apiKey }, select: { id: true } }),
|
||||
prisma.reverseProxyServer.findUnique({ where: { api_key: apiKey }, select: { id: true } }),
|
||||
]);
|
||||
|
||||
if (server) return { kind: "server" as const, id: server.id };
|
||||
if (proxy) return { kind: "reverse-proxy" as const, id: proxy.id };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function bearerToken(header: string | null): string {
|
||||
if (!header?.startsWith("Bearer ")) return "";
|
||||
return header.slice(7).trim();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { reverseProxyService, serverService } from "../application/di-container";
|
||||
import { requirePluginApiKey } from "../middleware/auth-guards";
|
||||
|
||||
export const pluginRoutes = new Elysia({ prefix: "/plugin" })
|
||||
.use(requirePluginApiKey)
|
||||
.get("/servers", async () => serverService.getAllServers(true))
|
||||
.get("/reverse-proxy", async () => reverseProxyService.getAllReverseProxies(true));
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { z } from "zod";
|
||||
import { reverseProxyService } from "../application/di-container";
|
||||
import { requireAuth } from "../middleware/auth-guards";
|
||||
import { createReverseProxySchema, updateReverseProxySchema } from "../schemas/server.schema";
|
||||
|
||||
const envVariableSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
import {
|
||||
createReverseProxySchema,
|
||||
envVariableSchema,
|
||||
updateReverseProxySchema,
|
||||
} from "../schemas/server.schema";
|
||||
|
||||
export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" })
|
||||
.use(requireAuth)
|
||||
@@ -19,6 +17,10 @@ export const reverseProxyRoutes = new Elysia({ prefix: "/reverse-proxy" })
|
||||
return await reverseProxyService.getReverseProxyById(params.id, false);
|
||||
})
|
||||
|
||||
.get("/:id/connection-info", async ({ params }) => {
|
||||
return await reverseProxyService.getConnectionInfo(params.id);
|
||||
})
|
||||
|
||||
.post("/", async ({ body }) => {
|
||||
const payload = createReverseProxySchema.parse(body);
|
||||
const proxy = await reverseProxyService.createReverseProxy(payload);
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { z } from "zod";
|
||||
import { serverService, wsService } from "../application/di-container";
|
||||
import { findApiKeyOwner } from "../middleware/api-key";
|
||||
import { requireAuth } from "../middleware/auth-guards";
|
||||
import { createServerSchema, updateServerSchema } from "../schemas/server.schema";
|
||||
import {
|
||||
createServerSchema,
|
||||
envVariableSchema,
|
||||
updateServerSchema,
|
||||
} from "../schemas/server.schema";
|
||||
import type { WebSocketClient } from "../services/websocket";
|
||||
|
||||
const envVariableSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const serverRoutes = new Elysia({ prefix: "/servers" })
|
||||
.ws("/ws", {
|
||||
open(ws: WebSocketClient & { data?: { query?: Record<string, string> }; close: () => void }) {
|
||||
if (!ws.data?.query?.apiKey) {
|
||||
async open(
|
||||
ws: WebSocketClient & { data?: { query?: Record<string, string> }; close: () => void }
|
||||
) {
|
||||
const owner = await findApiKeyOwner(ws.data?.query?.apiKey ?? "");
|
||||
if (!owner) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getErrorMessage } from "@minikura/shared/errors";
|
||||
import { Elysia } from "elysia";
|
||||
import { k8sService } from "../application/di-container";
|
||||
import { logger } from "../infrastructure/logger";
|
||||
import { requireAuth } from "../middleware/auth-guards";
|
||||
|
||||
type TerminalWsData = {
|
||||
query?: Record<string, string>;
|
||||
@@ -25,7 +26,7 @@ type BunTlsOptions = {
|
||||
ca?: string;
|
||||
};
|
||||
|
||||
export const terminalRoutes = new Elysia({ prefix: "/terminal" }).ws("/exec", {
|
||||
export const terminalRoutes = new Elysia({ prefix: "/terminal" }).use(requireAuth).ws("/exec", {
|
||||
open: async (ws: TerminalWs) => {
|
||||
const podName = ws.data.query?.podName;
|
||||
const container = ws.data.query?.container;
|
||||
|
||||
@@ -1,29 +1,27 @@
|
||||
import type { UpdateUserInput } from "@minikura/db";
|
||||
import { Elysia } from "elysia";
|
||||
import { userService } from "../application/di-container";
|
||||
import { requireAdmin, requireAuth } from "../middleware/auth-guards";
|
||||
import { requireAdmin } from "../middleware/auth-guards";
|
||||
import { updateSuspensionSchema, updateUserSchema } from "../schemas/user.schema";
|
||||
|
||||
export const userRoutes = new Elysia({ prefix: "/users" })
|
||||
.use(requireAdmin)
|
||||
.get("/", async () => {
|
||||
const users = await userService.getAllUsers();
|
||||
return users;
|
||||
return await userService.getAllUsers();
|
||||
})
|
||||
|
||||
.use(requireAuth)
|
||||
.get("/:id", async ({ params }) => {
|
||||
const foundUser = await userService.getUserById(params.id);
|
||||
return foundUser;
|
||||
return await userService.getUserById(params.id);
|
||||
})
|
||||
|
||||
.use(requireAdmin)
|
||||
.patch("/:id", async ({ params, body }) => {
|
||||
const input = body as UpdateUserInput;
|
||||
const updatedUser = await userService.updateUser(params.id, input);
|
||||
return updatedUser;
|
||||
const input = updateUserSchema.parse(body);
|
||||
return await userService.updateUser(params.id, input);
|
||||
})
|
||||
.patch("/:id/suspension", async ({ params, body }) => {
|
||||
const payload = updateSuspensionSchema.parse(body);
|
||||
return await userService.updateSuspension(params.id, {
|
||||
isSuspended: payload.isSuspended,
|
||||
suspendedUntil: payload.suspendedUntil ? new Date(payload.suspendedUntil) : null,
|
||||
});
|
||||
})
|
||||
|
||||
.use(requireAuth)
|
||||
.delete("/:id", async ({ params, user }) => {
|
||||
await userService.deleteUser(user.id, params.id);
|
||||
return { success: true };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ConnectionInfo, K8sNodeSummary, PodInfo } from "@minikura/api";
|
||||
import type { ConnectionInfo, CustomResourceSummary, K8sNodeSummary, PodInfo } from "@minikura/api";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { getReverseProxyApi } from "@/lib/api-helpers";
|
||||
@@ -119,6 +119,20 @@ export function useTopologyData() {
|
||||
nodeMetrics = nodeMetricsRes.data || { items: [] };
|
||||
} catch (_err) {}
|
||||
|
||||
const proxyBackends = new Map<string, string[]>();
|
||||
try {
|
||||
const crResponse = await api.api.k8s["reverse-proxy-servers"].get();
|
||||
for (const cr of (crResponse.data as CustomResourceSummary[]) || []) {
|
||||
const backends = cr.status?.backends;
|
||||
if (cr.name && Array.isArray(backends)) {
|
||||
proxyBackends.set(
|
||||
cr.name,
|
||||
backends.filter((id): id is string => typeof id === "string")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_err) {}
|
||||
|
||||
const topologyGraph = buildTopologyGraph({
|
||||
servers: normalServers,
|
||||
proxies: reverseProxies,
|
||||
@@ -127,6 +141,7 @@ export function useTopologyData() {
|
||||
k8sNodes,
|
||||
serverConnections: serverConnectionMap,
|
||||
proxyConnections: proxyConnectionMap,
|
||||
proxyBackends,
|
||||
podMetrics,
|
||||
nodeMetrics,
|
||||
});
|
||||
|
||||
@@ -59,15 +59,22 @@ interface BuildEnhancedGraphInput {
|
||||
k8sNodes: K8sNodeSummary[];
|
||||
serverConnections?: Map<string, ConnectionInfo | null>;
|
||||
proxyConnections?: Map<string, ConnectionInfo | null>;
|
||||
proxyBackends?: Map<string, string[]>;
|
||||
podMetrics?: any;
|
||||
nodeMetrics?: any;
|
||||
}
|
||||
|
||||
function parseProxyServerConnections(
|
||||
_proxy: ReverseProxyServer,
|
||||
allServers: NormalServer[]
|
||||
proxy: ReverseProxyServer,
|
||||
allServers: NormalServer[],
|
||||
backendsByProxyId?: Map<string, string[]>
|
||||
): string[] {
|
||||
return allServers.map((s) => s.id);
|
||||
const backends = backendsByProxyId?.get(proxy.id);
|
||||
if (!backends) {
|
||||
return allServers.map((s) => s.id);
|
||||
}
|
||||
const known = new Set(allServers.map((s) => s.id));
|
||||
return backends.filter((id) => known.has(id));
|
||||
}
|
||||
|
||||
export function buildTopologyGraph(input: BuildEnhancedGraphInput): TopologyGraph {
|
||||
@@ -79,6 +86,7 @@ export function buildTopologyGraph(input: BuildEnhancedGraphInput): TopologyGrap
|
||||
k8sNodes,
|
||||
serverConnections,
|
||||
proxyConnections,
|
||||
proxyBackends,
|
||||
podMetrics,
|
||||
nodeMetrics,
|
||||
} = input;
|
||||
@@ -103,7 +111,7 @@ export function buildTopologyGraph(input: BuildEnhancedGraphInput): TopologyGrap
|
||||
}
|
||||
|
||||
for (const proxy of proxies) {
|
||||
const connectedServerIds = parseProxyServerConnections(proxy, servers);
|
||||
const connectedServerIds = parseProxyServerConnections(proxy, servers, proxyBackends);
|
||||
proxyToServers.set(proxy.id, connectedServerIds);
|
||||
|
||||
for (const serverId of connectedServerIds) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@minikura/api": "workspace:*",
|
||||
"@minikura/backend": "workspace:*",
|
||||
"@minikura/shared": "workspace:*",
|
||||
"@radix-ui/react-accordion": "^1.2.20",
|
||||
"@radix-ui/react-avatar": "^1.2.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@minikura/api": "workspace:*",
|
||||
"@minikura/backend": "workspace:*",
|
||||
"@minikura/shared": "workspace:*",
|
||||
"@radix-ui/react-accordion": "^1.2.20",
|
||||
"@radix-ui/react-avatar": "^1.2.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
@@ -97,6 +98,9 @@
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
"operator": {
|
||||
"name": "@minikura/operator",
|
||||
},
|
||||
"packages/api": {
|
||||
"name": "@minikura/api",
|
||||
"dependencies": {
|
||||
@@ -333,6 +337,8 @@
|
||||
|
||||
"@minikura/db": ["@minikura/db@workspace:packages/db"],
|
||||
|
||||
"@minikura/operator": ["@minikura/operator@workspace:operator"],
|
||||
|
||||
"@minikura/shared": ["@minikura/shared@workspace:packages/shared"],
|
||||
|
||||
"@minikura/web": ["@minikura/web@workspace:apps/web"],
|
||||
|
||||
@@ -42,7 +42,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
|
||||
private val client = OkHttpClient()
|
||||
private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: ""
|
||||
private val apiUrl: String = System.getenv("MINIKURA_API_URL") ?: "http://localhost:3000/api"
|
||||
private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/ws?apiKey=$apiKey"
|
||||
private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/api/servers/ws?apiKey=$apiKey"
|
||||
private var acceptingTransfers = AtomicBoolean(false)
|
||||
private val redisBungeeApi = RedisBungeeAPI.getRedisBungeeApi()
|
||||
|
||||
@@ -158,7 +158,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
|
||||
|
||||
private fun fetchReverseProxyServers() {
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/reverse_proxy_servers")
|
||||
.url("$apiUrl/plugin/reverse-proxy")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.build()
|
||||
|
||||
@@ -185,7 +185,7 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
|
||||
private fun fetchServers() {
|
||||
server.allServers.forEach { server.unregisterServer(it.serverInfo) }
|
||||
val request = Request.Builder()
|
||||
.url("$apiUrl/servers")
|
||||
.url("$apiUrl/plugin/servers")
|
||||
.header("Authorization", "Bearer $apiKey")
|
||||
.build()
|
||||
|
||||
|
||||
+2
-9
@@ -30,14 +30,7 @@ class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logg
|
||||
"test" -> {
|
||||
logger.info("API Call detected: endpoint=$endpoint, timestamp=$timestamp")
|
||||
|
||||
when (endpoint) {
|
||||
"/servers" -> {
|
||||
logger.info("dawdawdawdawd")
|
||||
}
|
||||
else -> {
|
||||
logger.info("API endpoint $endpoint was accessed")
|
||||
}
|
||||
}
|
||||
logger.info("API endpoint $endpoint was accessed")
|
||||
}
|
||||
"SERVER_CHANGE" -> {
|
||||
val action = jsonObject.get("action")?.asString
|
||||
@@ -46,7 +39,7 @@ class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logg
|
||||
|
||||
logger.info("Server change detected: action=$action, serverType=$serverType, serverId=$serverId")
|
||||
|
||||
when (action) {
|
||||
when (action?.uppercase()) {
|
||||
"CREATE" -> {
|
||||
logger.info("Server '$serverId' was created")
|
||||
executeRefreshCommand()
|
||||
|
||||
Reference in New Issue
Block a user