mirror of
https://github.com/louislam/dockge.git
synced 2026-05-21 14:02:17 +00:00
Init (#1)
This commit is contained in:
262
backend/socket-handlers/docker-socket-handler.ts
Normal file
262
backend/socket-handlers/docker-socket-handler.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { SocketHandler } from "../socket-handler.js";
|
||||
import { DockgeServer } from "../dockge-server";
|
||||
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
|
||||
import { Stack } from "../stack";
|
||||
|
||||
// @ts-ignore
|
||||
import composerize from "composerize";
|
||||
|
||||
export class DockerSocketHandler extends SocketHandler {
|
||||
create(socket : DockgeSocket, server : DockgeServer) {
|
||||
|
||||
socket.on("deployStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
const stack = this.saveStack(socket, server, name, composeYAML, isAdd);
|
||||
await stack.deploy(socket);
|
||||
server.sendStackList();
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Deployed",
|
||||
});
|
||||
stack.joinCombinedTerminal(socket);
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("saveStack", async (name : unknown, composeYAML : unknown, isAdd : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
this.saveStack(socket, server, name, composeYAML, isAdd);
|
||||
callback({
|
||||
ok: true,
|
||||
"msg": "Saved"
|
||||
});
|
||||
server.sendStackList();
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("deleteStack", async (name : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
if (typeof(name) !== "string") {
|
||||
throw new ValidationError("Name must be a string");
|
||||
}
|
||||
const stack = Stack.getStack(server, name);
|
||||
|
||||
try {
|
||||
await stack.delete(socket);
|
||||
} catch (e) {
|
||||
server.sendStackList();
|
||||
throw e;
|
||||
}
|
||||
|
||||
server.sendStackList();
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Deleted"
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getStack", (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
|
||||
stack.joinCombinedTerminal(socket);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
stack: stack.toJSON(),
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// requestStackList
|
||||
socket.on("requestStackList", async (callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
server.sendStackList();
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Updated"
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// startStack
|
||||
socket.on("startStack", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
await stack.start(socket);
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Started"
|
||||
});
|
||||
server.sendStackList();
|
||||
|
||||
stack.joinCombinedTerminal(socket);
|
||||
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// stopStack
|
||||
socket.on("stopStack", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
await stack.stop(socket);
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Stopped"
|
||||
});
|
||||
server.sendStackList();
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// restartStack
|
||||
socket.on("restartStack", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
await stack.restart(socket);
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Restarted"
|
||||
});
|
||||
server.sendStackList();
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// updateStack
|
||||
socket.on("updateStack", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
await stack.update(socket);
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Updated"
|
||||
});
|
||||
server.sendStackList();
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// Services status
|
||||
socket.on("serviceStatusList", async (stackName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string");
|
||||
}
|
||||
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
const serviceStatusList = Object.fromEntries(await stack.getServiceStatusList());
|
||||
callback({
|
||||
ok: true,
|
||||
serviceStatusList,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// getExternalNetworkList
|
||||
socket.on("getDockerNetworkList", async (callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
const dockerNetworkList = server.getDockerNetworkList();
|
||||
callback({
|
||||
ok: true,
|
||||
dockerNetworkList,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// composerize
|
||||
socket.on("composerize", async (dockerRunCommand : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(dockerRunCommand) !== "string") {
|
||||
throw new ValidationError("dockerRunCommand must be a string");
|
||||
}
|
||||
|
||||
const composeTemplate = composerize(dockerRunCommand);
|
||||
callback({
|
||||
ok: true,
|
||||
composeTemplate,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveStack(socket : DockgeSocket, server : DockgeServer, name : unknown, composeYAML : unknown, isAdd : unknown) : Stack {
|
||||
// Check types
|
||||
if (typeof(name) !== "string") {
|
||||
throw new ValidationError("Name must be a string");
|
||||
}
|
||||
if (typeof(composeYAML) !== "string") {
|
||||
throw new ValidationError("Compose YAML must be a string");
|
||||
}
|
||||
if (typeof(isAdd) !== "boolean") {
|
||||
throw new ValidationError("isAdd must be a boolean");
|
||||
}
|
||||
|
||||
const stack = new Stack(server, name, composeYAML);
|
||||
stack.save(isAdd);
|
||||
return stack;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
295
backend/socket-handlers/main-socket-handler.ts
Normal file
295
backend/socket-handlers/main-socket-handler.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { SocketHandler } from "../socket-handler.js";
|
||||
import { Socket } from "socket.io";
|
||||
import { DockgeServer } from "../dockge-server";
|
||||
import { log } from "../log";
|
||||
import { R } from "redbean-node";
|
||||
import { loginRateLimiter, twoFaRateLimiter } from "../rate-limiter";
|
||||
import { generatePasswordHash, needRehashPassword, shake256, SHAKE256_LENGTH, verifyPassword } from "../password-hash";
|
||||
import { User } from "../models/user";
|
||||
import { checkLogin, DockgeSocket, doubleCheckPassword } from "../util-server";
|
||||
import { passwordStrength } from "check-password-strength";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { Settings } from "../settings";
|
||||
|
||||
export class MainSocketHandler extends SocketHandler {
|
||||
create(socket : DockgeSocket, server : DockgeServer) {
|
||||
|
||||
// ***************************
|
||||
// Public Socket API
|
||||
// ***************************
|
||||
|
||||
// Setup
|
||||
socket.on("setup", async (username, password, callback) => {
|
||||
try {
|
||||
if (passwordStrength(password).value === "Too weak") {
|
||||
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
|
||||
}
|
||||
|
||||
if ((await R.knex("user").count("id as count").first()).count !== 0) {
|
||||
throw new Error("Dockge has been initialized. If you want to run setup again, please delete the database.");
|
||||
}
|
||||
|
||||
const user = R.dispense("user");
|
||||
user.username = username;
|
||||
user.password = generatePasswordHash(password);
|
||||
await R.store(user);
|
||||
|
||||
server.needSetup = false;
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "successAdded",
|
||||
msgi18n: true,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Login by token
|
||||
socket.on("loginByToken", async (token, callback) => {
|
||||
const clientIP = await server.getClientIP(socket);
|
||||
|
||||
log.info("auth", `Login by token. IP=${clientIP}`);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, server.jwtSecret);
|
||||
|
||||
log.info("auth", "Username from JWT: " + decoded.username);
|
||||
|
||||
const user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||
decoded.username,
|
||||
]) as User;
|
||||
|
||||
if (user) {
|
||||
// Check if the password changed
|
||||
if (decoded.h !== shake256(user.password, SHAKE256_LENGTH)) {
|
||||
throw new Error("The token is invalid due to password change or old token");
|
||||
}
|
||||
|
||||
log.debug("auth", "afterLogin");
|
||||
await server.afterLogin(socket, user);
|
||||
log.debug("auth", "afterLogin ok");
|
||||
|
||||
log.info("auth", `Successfully logged in user ${decoded.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
} else {
|
||||
|
||||
log.info("auth", `Inactive or deleted user ${decoded.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "authUserInactiveOrDeleted",
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("auth", `Invalid token. IP=${clientIP}`);
|
||||
if (error.message) {
|
||||
log.error("auth", error.message, `IP=${clientIP}`);
|
||||
}
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "authInvalidToken",
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Login
|
||||
socket.on("login", async (data, callback) => {
|
||||
const clientIP = await server.getClientIP(socket);
|
||||
|
||||
log.info("auth", `Login by username + password. IP=${clientIP}`);
|
||||
|
||||
// Checking
|
||||
if (typeof callback !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Login Rate Limit
|
||||
if (!await loginRateLimiter.pass(callback)) {
|
||||
log.info("auth", `Too many failed requests for user ${data.username}. IP=${clientIP}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await this.login(data.username, data.password);
|
||||
|
||||
if (user) {
|
||||
if (user.twofa_status === 0) {
|
||||
server.afterLogin(socket, user);
|
||||
|
||||
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
token: User.createJWT(user, server.jwtSecret),
|
||||
});
|
||||
}
|
||||
|
||||
if (user.twofa_status === 1 && !data.token) {
|
||||
|
||||
log.info("auth", `2FA token required for user ${data.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
tokenRequired: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
const verify = notp.totp.verify(data.token, user.twofa_secret, twoFAVerifyOptions);
|
||||
|
||||
if (user.twofa_last_token !== data.token && verify) {
|
||||
server.afterLogin(socket, user);
|
||||
|
||||
await R.exec("UPDATE `user` SET twofa_last_token = ? WHERE id = ? ", [
|
||||
data.token,
|
||||
socket.userID,
|
||||
]);
|
||||
|
||||
log.info("auth", `Successfully logged in user ${data.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
token: User.createJWT(user, server.jwtSecret),
|
||||
});
|
||||
} else {
|
||||
|
||||
log.warn("auth", `Invalid token provided for user ${data.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "authInvalidToken",
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
log.warn("auth", `Incorrect username or password for user ${data.username}. IP=${clientIP}`);
|
||||
|
||||
callback({
|
||||
ok: false,
|
||||
msg: "authIncorrectCreds",
|
||||
msgi18n: true,
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Change Password
|
||||
socket.on("changePassword", async (password, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (! password.newPassword) {
|
||||
throw new Error("Invalid new password");
|
||||
}
|
||||
|
||||
if (passwordStrength(password.newPassword).value === "Too weak") {
|
||||
throw new Error("Password is too weak. It should contain alphabetic and numeric characters. It must be at least 6 characters in length.");
|
||||
}
|
||||
|
||||
let user = await doubleCheckPassword(socket, password.currentPassword);
|
||||
await user.resetPassword(password.newPassword);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Password has been updated successfully.",
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("getSettings", async (callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
const data = await Settings.getSettings("general");
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
data: data,
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("setSettings", async (data, currentPassword, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
// If currently is disabled auth, don't need to check
|
||||
// Disabled Auth + Want to Disable Auth => No Check
|
||||
// Disabled Auth + Want to Enable Auth => No Check
|
||||
// Enabled Auth + Want to Disable Auth => Check!!
|
||||
// Enabled Auth + Want to Enable Auth => No Check
|
||||
const currentDisabledAuth = await Settings.get("disableAuth");
|
||||
if (!currentDisabledAuth && data.disableAuth) {
|
||||
await doubleCheckPassword(socket, currentPassword);
|
||||
}
|
||||
|
||||
console.log(data);
|
||||
|
||||
await Settings.setSettings("general", data);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
msg: "Saved"
|
||||
});
|
||||
|
||||
server.sendInfo(socket);
|
||||
|
||||
} catch (e) {
|
||||
callback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login(username : string, password : string) {
|
||||
if (typeof username !== "string" || typeof password !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await R.findOne("user", " username = ? AND active = 1 ", [
|
||||
username,
|
||||
]);
|
||||
|
||||
if (user && verifyPassword(password, user.password)) {
|
||||
// Upgrade the hash to bcrypt
|
||||
if (needRehashPassword(user.password)) {
|
||||
await R.exec("UPDATE `user` SET password = ? WHERE id = ? ", [
|
||||
generatePasswordHash(password),
|
||||
user.id,
|
||||
]);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
151
backend/socket-handlers/terminal-socket-handler.ts
Normal file
151
backend/socket-handlers/terminal-socket-handler.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { SocketHandler } from "../socket-handler.js";
|
||||
import { DockgeServer } from "../dockge-server";
|
||||
import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server";
|
||||
import { log } from "../log";
|
||||
import yaml from "yaml";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import {
|
||||
allowedCommandList,
|
||||
allowedRawKeys,
|
||||
getComposeTerminalName, getContainerExecTerminalName,
|
||||
isDev,
|
||||
PROGRESS_TERMINAL_ROWS
|
||||
} from "../util-common";
|
||||
import { InteractiveTerminal, MainTerminal, Terminal } from "../terminal";
|
||||
import { Stack } from "../stack";
|
||||
|
||||
export class TerminalSocketHandler extends SocketHandler {
|
||||
create(socket : DockgeSocket, server : DockgeServer) {
|
||||
|
||||
socket.on("terminalInput", async (terminalName : unknown, cmd : unknown, errorCallback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(terminalName) !== "string") {
|
||||
throw new Error("Terminal name must be a string.");
|
||||
}
|
||||
|
||||
if (typeof(cmd) !== "string") {
|
||||
throw new Error("Command must be a string.");
|
||||
}
|
||||
|
||||
let terminal = Terminal.getTerminal(terminalName);
|
||||
if (terminal instanceof InteractiveTerminal) {
|
||||
//log.debug("terminalInput", "Terminal found, writing to terminal.");
|
||||
terminal.write(cmd);
|
||||
} else {
|
||||
throw new Error("Terminal not found or it is not a Interactive Terminal.");
|
||||
}
|
||||
} catch (e) {
|
||||
errorCallback({
|
||||
ok: false,
|
||||
msg: e.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Main Terminal
|
||||
socket.on("mainTerminal", async (terminalName : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
// TODO: Reset the name here, force one main terminal for now
|
||||
terminalName = "console";
|
||||
|
||||
if (typeof(terminalName) !== "string") {
|
||||
throw new ValidationError("Terminal name must be a string.");
|
||||
}
|
||||
|
||||
log.debug("deployStack", "Terminal name: " + terminalName);
|
||||
|
||||
let terminal = Terminal.getTerminal(terminalName);
|
||||
|
||||
if (!terminal) {
|
||||
terminal = new MainTerminal(server, terminalName);
|
||||
terminal.rows = 50;
|
||||
log.debug("deployStack", "Terminal created");
|
||||
}
|
||||
|
||||
terminal.join(socket);
|
||||
terminal.start();
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// Interactive Terminal for containers
|
||||
socket.on("interactiveTerminal", async (stackName : unknown, serviceName : unknown, shell : unknown, callback) => {
|
||||
try {
|
||||
checkLogin(socket);
|
||||
|
||||
if (typeof(stackName) !== "string") {
|
||||
throw new ValidationError("Stack name must be a string.");
|
||||
}
|
||||
|
||||
if (typeof(serviceName) !== "string") {
|
||||
throw new ValidationError("Service name must be a string.");
|
||||
}
|
||||
|
||||
if (typeof(shell) !== "string") {
|
||||
throw new ValidationError("Shell must be a string.");
|
||||
}
|
||||
|
||||
log.debug("interactiveTerminal", "Stack name: " + stackName);
|
||||
log.debug("interactiveTerminal", "Service name: " + serviceName);
|
||||
|
||||
// Get stack
|
||||
const stack = Stack.getStack(server, stackName);
|
||||
stack.joinContainerTerminal(socket, serviceName, shell);
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// Join Output Terminal
|
||||
socket.on("terminalJoin", async (terminalName : unknown, callback) => {
|
||||
if (typeof(callback) !== "function") {
|
||||
log.debug("console", "Callback is not a function.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
checkLogin(socket);
|
||||
if (typeof(terminalName) !== "string") {
|
||||
throw new ValidationError("Terminal name must be a string.");
|
||||
}
|
||||
|
||||
let buffer : string = Terminal.getTerminal(terminalName)?.getBuffer() ?? "";
|
||||
|
||||
if (!buffer) {
|
||||
log.debug("console", "No buffer found.");
|
||||
}
|
||||
|
||||
callback({
|
||||
ok: true,
|
||||
buffer,
|
||||
});
|
||||
} catch (e) {
|
||||
callbackError(e, callback);
|
||||
}
|
||||
});
|
||||
|
||||
// Close Terminal
|
||||
socket.on("terminalClose", async (terminalName : unknown, callback : unknown) => {
|
||||
|
||||
});
|
||||
|
||||
// TODO: Resize Terminal
|
||||
socket.on("terminalResize", async (rows : unknown) => {
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user