Files
collabvm-1.2.ts/cvmts/src/User.ts
modeco80 432e75d42a cvmts: Use npm versions of superqemu/nodejs-rfb.
We publish them now, so let's use them in cvmts!

Additionally, this removes the 'shared' module entirely, since it has little purpose anymore. The logger is replaced with pino (because superqemu uses pino for logging itself).
2024-07-16 08:29:52 -04:00

182 lines
5.4 KiB
TypeScript

import * as Utilities from './Utilities.js';
import * as cvm from '@cvmts/cvm-rs';
import { IPData } from './IPData.js';
import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js';
import { execa, execaCommand, ExecaSyncError } from 'execa';
import NetworkClient from './NetworkClient.js';
import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol';
import pino from 'pino';
export class User {
socket: NetworkClient;
nopSendInterval: NodeJS.Timeout;
msgRecieveInterval: NodeJS.Timeout;
nopRecieveTimeout?: NodeJS.Timeout;
username?: string;
connectedToNode: boolean;
viewMode: number;
rank: Rank;
msgsSent: number;
Config: IConfig;
IP: IPData;
Capabilities: CollabVMCapabilities;
// Hide flag. Only takes effect if the user is logged in.
noFlag: boolean = false;
countryCode: string | null = null;
// Rate limiters
ChatRateLimit: RateLimiter;
LoginRateLimit: RateLimiter;
RenameRateLimit: RateLimiter;
TurnRateLimit: RateLimiter;
VoteRateLimit: RateLimiter;
private logger = pino({ name: 'CVMTS.User' });
constructor(socket: NetworkClient, ip: IPData, config: IConfig, username?: string, node?: string) {
this.IP = ip;
this.connectedToNode = false;
this.viewMode = -1;
this.Config = config;
this.socket = socket;
this.msgsSent = 0;
this.Capabilities = new CollabVMCapabilities();
this.socket.on('disconnect', () => {
// Unref the ip data for this connection
this.IP.Unref();
clearInterval(this.nopSendInterval);
clearInterval(this.msgRecieveInterval);
});
this.socket.on('msg', (e) => {
clearTimeout(this.nopRecieveTimeout);
clearInterval(this.msgRecieveInterval);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
});
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
this.sendNop();
if (username) this.username = username;
this.rank = 0;
this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds);
this.ChatRateLimit.on('limit', () => this.mute(false));
this.RenameRateLimit = new RateLimiter(3, 60);
this.RenameRateLimit.on('limit', () => this.closeConnection());
this.LoginRateLimit = new RateLimiter(4, 3);
this.LoginRateLimit.on('limit', () => this.closeConnection());
this.TurnRateLimit = new RateLimiter(5, 3);
this.TurnRateLimit.on('limit', () => this.closeConnection());
this.VoteRateLimit = new RateLimiter(3, 3);
this.VoteRateLimit.on('limit', () => this.closeConnection());
}
assignGuestName(existingUsers: string[]): string {
var username;
do {
username = 'guest' + Utilities.Randint(10000, 99999);
} while (existingUsers.indexOf(username) !== -1);
this.username = username;
return username;
}
sendNop() {
this.socket.send('3.nop;');
}
sendMsg(msg: string) {
if (!this.socket.isOpen()) return;
clearInterval(this.nopSendInterval);
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.socket.send(msg);
}
private onNoMsg() {
this.sendNop();
this.nopRecieveTimeout = setTimeout(() => {
this.closeConnection();
}, 3000);
}
closeConnection() {
this.socket.send(cvm.guacEncode('disconnect'));
this.socket.close();
}
onMsgSent() {
if (!this.Config.collabvm.automute.enabled) return;
// rate limit guest and unregistered chat messages, but not staff ones
switch (this.rank) {
case Rank.Moderator:
case Rank.Admin:
break;
default:
this.ChatRateLimit.request();
break;
}
}
mute(permanent: boolean) {
this.IP.muted = true;
this.sendMsg(cvm.guacEncode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
}
}
unmute() {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.muted = false;
this.sendMsg(cvm.guacEncode('chat', '', 'You are no longer muted.'));
}
private banCmdArgs(arg: string): string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || '');
}
async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == 'string') {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async kick() {
this.sendMsg('10.disconnect;');
this.socket.close();
}
}
export enum Rank {
Unregistered = 0,
// After all these years
Registered = 1,
Admin = 2,
Moderator = 3,
// Giving a good gap between server only internal ranks just in case
Turn = 10
}