Connect to remote Dokku server using SSH.
This commit is contained in:
parent
769f52816f
commit
de4923ec1e
87
backend/server/package-lock.json
generated
87
backend/server/package-lock.json
generated
@ -16,12 +16,14 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
|
"ssh2": "^1.15.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/ssh2": "^1.15.0",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
@ -213,6 +215,24 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ssh2": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^18.11.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ssh2/node_modules/@types/node": {
|
||||||
|
"version": "18.19.41",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.41.tgz",
|
||||||
|
"integrity": "sha512-LX84pRJ+evD2e2nrgYCHObGWkiQJ1mL+meAgbvnwk/US6vmMY7S2ygBTGV2Jw91s9vUsLSXeDEkUHZIJGLrhsg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||||
@ -298,6 +318,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1": {
|
||||||
|
"version": "0.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
|
||||||
|
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": "~2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@ -312,6 +340,14 @@
|
|||||||
"node": "^4.5.0 || >= 5.9"
|
"node": "^4.5.0 || >= 5.9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt-pbkdf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^0.14.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@ -382,6 +418,15 @@
|
|||||||
"node": ">=6.14.2"
|
"node": ">=6.14.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buildcheck": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@ -593,6 +638,20 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cpu-features": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buildcheck": "~0.0.6",
|
||||||
|
"nan": "^2.19.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/create-require": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@ -1247,6 +1306,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
|
||||||
},
|
},
|
||||||
|
"node_modules/nan": {
|
||||||
|
"version": "2.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz",
|
||||||
|
"integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@ -1748,6 +1813,23 @@
|
|||||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="
|
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/ssh2": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.6",
|
||||||
|
"bcrypt-pbkdf": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"cpu-features": "~0.0.9",
|
||||||
|
"nan": "^2.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||||
@ -1880,6 +1962,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "0.14.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||||
|
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
@ -18,12 +18,14 @@
|
|||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"rate-limiter-flexible": "^5.0.3",
|
"rate-limiter-flexible": "^5.0.3",
|
||||||
"socket.io": "^4.7.5",
|
"socket.io": "^4.7.5",
|
||||||
|
"ssh2": "^1.15.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.12.7",
|
"@types/node": "^20.12.7",
|
||||||
|
"@types/ssh2": "^1.15.0",
|
||||||
"nodemon": "^3.1.0",
|
"nodemon": "^3.1.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
|
37
backend/server/src/DokkuClient.ts
Normal file
37
backend/server/src/DokkuClient.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { SSHSocketClient, SSHConfig } from "./SSHSocketClient"
|
||||||
|
|
||||||
|
export interface DokkuResponse {
|
||||||
|
ok: boolean;
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DokkuClient extends SSHSocketClient {
|
||||||
|
|
||||||
|
constructor(config: SSHConfig) {
|
||||||
|
super(
|
||||||
|
config,
|
||||||
|
"/var/run/dokku-daemon/dokku-daemon.sock"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendCommand(command: string): Promise<DokkuResponse> {
|
||||||
|
try {
|
||||||
|
const response = await this.sendData(command);
|
||||||
|
|
||||||
|
if (typeof response !== "string") {
|
||||||
|
throw new Error("Received data is not a string");
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(response);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to send command: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listApps(): Promise<string[]> {
|
||||||
|
const response = await this.sendCommand("apps:list");
|
||||||
|
return response.output.split("\n").slice(1); // Split by newline and ignore the first line (header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SSHConfig };
|
90
backend/server/src/SSHSocketClient.ts
Normal file
90
backend/server/src/SSHSocketClient.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { Client } from "ssh2";
|
||||||
|
|
||||||
|
export interface SSHConfig {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
privateKey: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SSHSocketClient {
|
||||||
|
private conn: Client;
|
||||||
|
private config: SSHConfig;
|
||||||
|
private socketPath: string;
|
||||||
|
private isConnected: boolean = false;
|
||||||
|
|
||||||
|
constructor(config: SSHConfig, socketPath: string) {
|
||||||
|
this.conn = new Client();
|
||||||
|
this.config = { ...config, port: 22};
|
||||||
|
this.socketPath = socketPath;
|
||||||
|
|
||||||
|
this.setupTerminationHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupTerminationHandlers() {
|
||||||
|
process.on("SIGINT", this.closeConnection.bind(this));
|
||||||
|
process.on("SIGTERM", this.closeConnection.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeConnection() {
|
||||||
|
console.log("Closing SSH connection...");
|
||||||
|
this.conn.end();
|
||||||
|
this.isConnected = false;
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.conn
|
||||||
|
.on("ready", () => {
|
||||||
|
console.log("SSH connection established");
|
||||||
|
this.isConnected = true;
|
||||||
|
resolve();
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error("SSH connection error:", err);
|
||||||
|
this.isConnected = false;
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.on("close", () => {
|
||||||
|
console.log("SSH connection closed");
|
||||||
|
this.isConnected = false;
|
||||||
|
})
|
||||||
|
.connect(this.config);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendData(data: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.isConnected) {
|
||||||
|
reject(new Error("SSH connection is not established"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conn.exec(
|
||||||
|
`echo "${data}" | nc -U ${this.socketPath}`,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream
|
||||||
|
.on("close", (code: number, signal: string) => {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Stream closed with code ${code} and signal ${signal}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.on("data", (data: Buffer) => {
|
||||||
|
resolve(data.toString());
|
||||||
|
})
|
||||||
|
.stderr.on("data", (data: Buffer) => {
|
||||||
|
reject(new Error(data.toString()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -5,7 +5,8 @@ import express, { Express } from "express";
|
|||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { createServer } from "http";
|
import { createServer } from "http";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import net from "net";
|
import { DokkuClient, SSHConfig } from "./DokkuClient";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { User } from "./types";
|
import { User } from "./types";
|
||||||
@ -113,20 +114,17 @@ io.use(async (socket, next) => {
|
|||||||
|
|
||||||
const lockManager = new LockManager();
|
const lockManager = new LockManager();
|
||||||
|
|
||||||
const client = net.createConnection(
|
if (!process.env.DOKKU_HOST) throw new Error('Environment variable DOKKU_HOST is not defined');
|
||||||
{ path: "/var/run/remote-dokku.sock" },
|
if (!process.env.DOKKU_USERNAME) throw new Error('Environment variable DOKKU_USERNAME is not defined');
|
||||||
() => {
|
if (!process.env.DOKKU_KEY) throw new Error('Environment variable DOKKU_KEY is not defined');
|
||||||
console.log("Connected to Dokku server");
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
client.on("end", () => {
|
const client = new DokkuClient({
|
||||||
console.log("Disconnected from Dokku server");
|
host: process.env.DOKKU_HOST,
|
||||||
|
username: process.env.DOKKU_USERNAME,
|
||||||
|
privateKey: fs.readFileSync(process.env.DOKKU_KEY),
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.connect();
|
||||||
console.error(`Dokku Client error: ${err}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
io.on("connection", async (socket) => {
|
io.on("connection", async (socket) => {
|
||||||
try {
|
try {
|
||||||
@ -270,11 +268,6 @@ io.on("connection", async (socket) => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
interface DokkuResponse {
|
|
||||||
ok: boolean;
|
|
||||||
output: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CallbackResponse {
|
interface CallbackResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
apps?: string[];
|
apps?: string[];
|
||||||
@ -285,20 +278,17 @@ io.on("connection", async (socket) => {
|
|||||||
"list",
|
"list",
|
||||||
async (callback: (response: CallbackResponse) => void) => {
|
async (callback: (response: CallbackResponse) => void) => {
|
||||||
console.log("Retrieving apps list...");
|
console.log("Retrieving apps list...");
|
||||||
client.on("data", (data) => {
|
try {
|
||||||
const response = data.toString();
|
callback({
|
||||||
const parsedData: DokkuResponse = JSON.parse(response);
|
success: true,
|
||||||
if (parsedData.ok) {
|
apps: await client.listApps()
|
||||||
const appsList = parsedData.output.split("\n").slice(1); // Split by newline and ignore the first line (header)
|
});
|
||||||
callback({ success: true, apps: appsList });
|
} catch (error) {
|
||||||
} else {
|
|
||||||
callback({
|
callback({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Failed to retrieve apps list",
|
message: "Failed to retrieve apps list",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
client.write("apps:list\n");
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user