diff --git a/config.example.toml b/config.example.toml index b47b920..d6d954f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -29,6 +29,8 @@ automute = {enabled = true, seconds = 5, messages = 5} tempMuteTime = 30 # How long a turn lasts, in seconds turnTime = 20 +# How long a reset vote lasts, in seconds +voteTime = 100 # SHA256 sum of the admin and mod passwords. This can be generated with the following command: # printf "" | sha256sum - # Example hash is hunter2 and hunter3 diff --git a/src/IConfig.ts b/src/IConfig.ts index dae0a06..1a9e3c4 100644 --- a/src/IConfig.ts +++ b/src/IConfig.ts @@ -26,6 +26,7 @@ export default interface IConfig { }; tempMuteTime : number; turnTime : number; + voteTime : number; adminpass : string; modpass : string; moderatorPermissions : Permissions; diff --git a/src/QEMUVM.ts b/src/QEMUVM.ts index 051581f..e372abf 100644 --- a/src/QEMUVM.ts +++ b/src/QEMUVM.ts @@ -16,6 +16,7 @@ export default class QEMUVM extends EventEmitter { vncErrorLevel : number; processRestartErrorLevel : number; expectedExit : boolean; + vncOpen : boolean; vncReconnectTimeout? : NodeJS.Timer; qmpReconnectTimeout? : NodeJS.Timer; @@ -32,6 +33,7 @@ export default class QEMUVM extends EventEmitter { this.qemuCmd = `${Config.vm.qemuArgs} -snapshot -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp unix:${this.qmpSock},server`; this.qmpErrorLevel = 0; this.vncErrorLevel = 0; + this.vncOpen = true; this.processRestartErrorLevel = 0; this.expectedExit = false; this.qmpClient = new QMPClient(this.qmpSock); @@ -50,13 +52,14 @@ export default class QEMUVM extends EventEmitter { } var qemuArr = this.qemuCmd.split(" "); this.qemuProcess = execa(qemuArr[0], qemuArr.slice(1)); + this.qemuProcess.catch(() => false); this.qemuProcess.stderr?.on('data', (d) => console.log(d.toString())); - this.qemuProcess.on('spawn', () => { + this.qemuProcess.once('spawn', () => { setTimeout(async () => { await this.qmpClient.connect(); - }, 1000) + }, 2000) }); - this.qemuProcess.on('exit', () => { + this.qemuProcess.once('exit', () => { if (this.expectedExit) return; clearTimeout(this.qmpReconnectTimeout); clearTimeout(this.vncReconnectTimeout); @@ -70,6 +73,7 @@ export default class QEMUVM extends EventEmitter { this.vnc?.end(); this.qemuRestartTimeout = setTimeout(() => this.Start(), 3000); }); + this.qemuProcess.on('error', () => false); this.once('vncconnect', () => res()); }); } @@ -109,6 +113,7 @@ export default class QEMUVM extends EventEmitter { } private vncClosed() { + this.vncOpen = false; if (this.expectedExit) return; this.vncErrorLevel++; if (this.vncErrorLevel > 4) { @@ -123,8 +128,11 @@ export default class QEMUVM extends EventEmitter { } private vncConnected() { + this.vncOpen = true; this.emit('vncconnect'); this.vncErrorLevel = 0; + //@ts-ignore + this.onVNCSize({height: this.vnc.height, width: this.vnc.width}); } private async onVNCRect(rect : any) { var buff = Buffer.alloc(rect.height * rect.width * 4) @@ -137,7 +145,8 @@ export default class QEMUVM extends EventEmitter { } this.emit("dirtyrect", buff, rect.x, rect.y, rect.width, rect.height); if (!this.vnc) throw new Error(); - this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width); + if (this.vncOpen) + this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width); } private onVNCSize(size : any) { @@ -149,6 +158,7 @@ export default class QEMUVM extends EventEmitter { } async Restore() { + if (this.expectedExit) return; await this.Stop(); this.expectedExit = false; this.Start(); @@ -156,14 +166,23 @@ export default class QEMUVM extends EventEmitter { Stop() : Promise { return new Promise(async (res, rej) => { + if (this.expectedExit) {res(); return;} + if (!this.qemuProcess) throw new Error("VM was not running"); this.expectedExit = true; + this.vncOpen = false; this.vnc?.end(); - this.qmpClient.disconnect(); var killTimeout = setTimeout(() => { console.log("Force killing QEMU after 10 seconds of waiting for shutdown"); this.qemuProcess?.kill(9); - }, 10000) - await this.qemuProcess; + }, 10000); + var closep = new Promise(async (reso, reje) => { + this.qemuProcess?.once('exit', () => reso()); + await this.qmpClient.execute({ "execute": "quit" }); + }); + var qmpclosep = new Promise((reso, rej) => { + this.qmpClient.once('close', () => reso()); + }); + await Promise.all([closep, qmpclosep]); clearTimeout(killTimeout); res(); }) diff --git a/src/QMPClient.ts b/src/QMPClient.ts index ed213bb..77576ed 100644 --- a/src/QMPClient.ts +++ b/src/QMPClient.ts @@ -25,10 +25,10 @@ export default class QMPClient extends EventEmitter { this.onClose(); } this.connected = true; - this.socket.on('error', (err) => false); // Disable throwing if QMP errors + this.socket.on('error', (err) => console.log(err)); // Disable throwing if QMP errors this.socket.on('data', (data) => this.onData(data)); this.socket.on('close', () => this.onClose()); - this.once('connected', () => res()); + this.once('connected', () => {res();}); }) } @@ -39,10 +39,13 @@ export default class QMPClient extends EventEmitter { private async onData(data : Buffer) { var msgraw = data.toString(); - var msg = JSON.parse(msgraw); + var msg; + try {msg = JSON.parse(msgraw);} + catch {return;} if (msg.QMP) { - if (this.sentConnected) return; + if (this.sentConnected) {return;}; await this.execute({ execute: "qmp_capabilities" }); + this.emit('connected'); this.sentConnected = true; } @@ -52,6 +55,11 @@ export default class QMPClient extends EventEmitter { private onClose() { this.connected = false; this.sentConnected = false; + if (this.socket.readyState === 'open') + this.socket.destroy(); + this.cmdMutex.cancel(); + this.cmdMutex.release(); + this.socket = new Socket(); this.emit('close'); } @@ -67,15 +75,18 @@ export default class QMPClient extends EventEmitter { execute(args : object) { return new Promise(async (res, rej) => { - var result:any = await this.cmdMutex.runExclusive(() => { - // I kinda hate having two promises but IDK how else to do it /shrug - return new Promise((reso, reje) => { - this.once('qmpreturn', (e) => { - reso(e); + var result:any; + try { + result = await this.cmdMutex.runExclusive(() => { + // I kinda hate having two promises but IDK how else to do it /shrug + return new Promise((reso, reje) => { + this.once('qmpreturn', (e) => { + reso(e); + }); + this.socket.write(JSON.stringify(args)); }); - this.socket.write(JSON.stringify(args)); }); - }); + } catch {res({})}; res(result); }); } diff --git a/src/User.ts b/src/User.ts index 6bfcde6..2eeaad1 100644 --- a/src/User.ts +++ b/src/User.ts @@ -3,6 +3,7 @@ import * as guacutils from './guacutils.js'; import {WebSocket} from 'ws'; import IConfig from './IConfig.js'; import RateLimiter from './RateLimiter.js'; +import { execaCommand } from 'execa'; export class User { socket : WebSocket; nopSendInterval : NodeJS.Timer; @@ -16,6 +17,7 @@ export class User { msgsSent : number; Config : IConfig; IP : string; + vote : boolean | null; // Rate limiters ChatRateLimit : RateLimiter; LoginRateLimit : RateLimiter; @@ -28,6 +30,7 @@ export class User { this.socket = ws; this.muted = false; this.msgsSent = 0; + this.vote = null; this.socket.on('close', () => { clearInterval(this.nopSendInterval); }); @@ -95,6 +98,20 @@ export class User { this.muted = false; this.sendMsg(guacutils.encode("chat", "", "You are no longer muted.")); } + + async ban() { + // Prevent the user from taking turns or chatting, in case the ban command takes a while + this.muted = true; + //@ts-ignore + var cmd = this.Config.collabvm.bancmd.replace(/\$IP/g, this.IP).replace(/\$NAME/g, this.username); + await execaCommand(cmd); + this.kick(); + } + + async kick() { + this.sendMsg("10.disconnect"); + this.socket.close(); + } } export enum Rank { diff --git a/src/WSServer.ts b/src/WSServer.ts index f9be9a2..67fc48b 100644 --- a/src/WSServer.ts +++ b/src/WSServer.ts @@ -5,7 +5,6 @@ import internal from 'stream'; import * as Utilities from './Utilities.js'; import { User, Rank } from './User.js'; import * as guacutils from './guacutils.js'; -import * as fs from 'fs'; // I hate that you have to do it like this import CircularBuffer from 'mnemonist/circular-buffer.js'; import Queue from 'mnemonist/queue.js'; @@ -13,7 +12,7 @@ import { createHash } from 'crypto'; import { isIP } from 'net'; import QEMUVM from './QEMUVM.js'; import Framebuffer from './Framebuffer.js'; -import sharp, { Sharp } from 'sharp'; +import sharp from 'sharp'; export default class WSServer { private Config : IConfig; @@ -22,9 +21,22 @@ export default class WSServer { private clients : User[]; private ChatHistory : CircularBuffer<{user:string,msg:string}> private TurnQueue : Queue; + // Time remaining on the current turn private TurnTime : number; + // Interval to keep track of the current turn time private TurnInterval? : NodeJS.Timer; + // Is the turn interval running? private TurnIntervalRunning : boolean; + // If a reset vote is in progress + private voteInProgress : boolean; + // Interval to keep track of vote resets + private voteInterval? : NodeJS.Timer; + // How much time is left on the vote + private voteTime : number; + // How much time until another reset vote can be cast + private voteTimeout : number; + // Interval to keep track + private voteTimeoutInterval? : NodeJS.Timer; private ModPerms : number; private VM : QEMUVM; private framebuffer : Framebuffer; @@ -35,6 +47,9 @@ export default class WSServer { this.TurnIntervalRunning = false; this.clients = []; this.Config = config; + this.voteInProgress = false; + this.voteTime = 0; + this.voteTimeout = 0; this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions); this.server = http.createServer(); this.socket = new WebSocketServer({noServer: true}); @@ -156,49 +171,12 @@ export default class WSServer { var jpg = await sharp(await this.framebuffer.getFb(), {raw: {height: this.framebuffer.size.height, width: this.framebuffer.size.width, channels: 4}}).jpeg().toBuffer(); var jpg64 = jpg.toString("base64"); client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64)); + if (this.voteInProgress) this.sendVoteUpdate(client); + this.sendTurnUpdate(client); break; case "rename": if (!client.RenameRateLimit.request()) return; - // This shouldn't need a ternary but it does for some reason - var hadName : boolean = client.username ? true : false; - var oldname : any; - if (hadName) oldname = client.username; - if (msgArr.length === 1) { - client.assignGuestName(this.getUsernameList()); - } else { - var newName = msgArr[1]; - if (hadName && newName === oldname) { - //@ts-ignore - client.sendMsg(guacutils.encode("rename", "0", "0", client.username)); - return; - } - if (this.getUsernameList().indexOf(newName) !== -1) { - client.sendMsg(guacutils.encode("rename", "0", "1")); - return; - } - if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName)) { - client.sendMsg(guacutils.encode("rename", "0", "2")); - return; - } - if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { - client.sendMsg(guacutils.encode("rename", "0", "3")); - return; - } - client.username = newName; - } - //@ts-ignore - client.sendMsg(guacutils.encode("rename", "0", "0", client.username)); - if (hadName) { - console.log(`[RENAME] ${client.IP} from ${oldname} to ${client.username}`); - this.clients.filter(c => c.username !== client.username).forEach((c) => - //@ts-ignore - c.sendMsg(guacutils.encode("rename", "1", oldname, client.username))); - } else { - console.log(`[RENAME] ${client.IP} to ${client.username}`); - this.clients.forEach((c) => - //@ts-ignore - c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); - } + this.renameUser(client, msgArr[1]); break; case "chat": if (!client.username) return; @@ -247,6 +225,7 @@ export default class WSServer { break; case "mouse": if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + if (!this.VM.vncOpen) return; if (!this.VM.vnc) throw new Error("VNC Client was undefined"); var x = parseInt(msgArr[1]); var y = parseInt(msgArr[2]); @@ -256,16 +235,44 @@ export default class WSServer { break; case "key": if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return; + if (!this.VM.vncOpen) return; if (!this.VM.vnc) throw new Error("VNC Client was undefined"); var keysym = parseInt(msgArr[1]); var down = parseInt(msgArr[2]); if (keysym === undefined || (down !== 0 && down !== 1)) return; this.VM.vnc.keyEvent(keysym, down); break; + case "vote": + if (!client.connectedToNode) return; + if (msgArr.length !== 2) return; + switch (msgArr[1]) { + case "1": + if (!this.voteInProgress) { + if (this.voteTimeout !== 0) { + client.sendMsg(guacutils.encode("vote", "3", this.voteTimeout.toString())); + return; + } + this.startVote(); + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has started a vote to reset the VM.`))); + } + else if (client.vote !== true) + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`))); + client.vote = true; + break; + case "0": + if (!this.voteInProgress) return; + if (client.vote !== false) + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`))); + client.vote = false; + break; + } + this.sendVoteUpdate(); + break; case "admin": if (msgArr.length < 2) return; switch (msgArr[1]) { case "2": + // Login if (!client.LoginRateLimit.request()) return; if (msgArr.length !== 3) return; var sha256 = createHash("sha256"); @@ -285,7 +292,124 @@ export default class WSServer { //@ts-ignore this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); break; - + case "5": + // QEMU Monitor + if (client.rank !== Rank.Admin) return; + if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return; + var output = await this.VM.qmpClient.runMonitorCmd(msgArr[3]); + client.sendMsg(guacutils.encode("admin", "2", String(output))); + break; + case "8": + // Restore + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return; + this.VM.Restore(); + break; + case "10": + // Reboot + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return; + if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; + this.VM.Reboot(); + break; + case "12": + // Ban + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + user.ban(); + case "13": + // Force Vote + if (msgArr.length !== 3) return; + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return; + if (!this.voteInProgress) return; + switch (msgArr[2]) { + case "1": + this.endVote(true); + break; + case "0": + this.endVote(false); + break; + } + break; + case "14": + // Mute + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return; + if (msgArr.length !== 4) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + var permamute; + switch (msgArr[3]) { + case "0": + permamute = false; + break; + case "1": + permamute = true; + break; + default: + return; + } + user.mute(permamute); + break; + case "15": + // Kick + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + user.kick(); + break; + case "16": + // End turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + this.endTurn(user); + break; + case "17": + // Clear turn queue + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return; + this.clearTurns(); + break; + case "18": + // Rename user + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return; + if (msgArr.length !== 4) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + this.renameUser(user, msgArr[3]); + break; + case "19": + // Get IP + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return; + if (msgArr.length !== 3) return; + var user = this.clients.find(c => c.username === msgArr[2]); + if (!user) return; + client.sendMsg(guacutils.encode("admin", "19", msgArr[2], user.IP)); + break; + case "20": + // Steal turn + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return; + this.bypassTurn(client); + break; + case "21": + // XSS + if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return; + if (msgArr.length !== 3) return; + switch (client.rank) { + case Rank.Admin: + //@ts-ignore + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username, msgArr[2]))); + //@ts-ignore + this.ChatHistory.push({user: client.username, msg: msgArr[2]}); + break; + case Rank.Moderator: + //@ts-ignore + this.clients.filter(c => c.rank !== Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username, msgArr[2]))); + //@ts-ignore + this.clients.filter(c => c.rank === Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username, Utilities.HTMLSanitize(msgArr[2])))); + break; + } + break; } break; @@ -298,6 +422,49 @@ export default class WSServer { this.clients.filter(c => c.username).forEach((c) => arr.push(c.username)); return arr; } + + renameUser(client : User, newName? : string) { + // This shouldn't need a ternary but it does for some reason + var hadName : boolean = client.username ? true : false; + var oldname : any; + if (hadName) oldname = client.username; + var status = "0"; + if (!newName) { + client.assignGuestName(this.getUsernameList()); + } else { + if (hadName && newName === oldname) { + //@ts-ignore + client.sendMsg(guacutils.encode("rename", "0", "0", client.username)); + return; + } + if (this.getUsernameList().indexOf(newName) !== -1) { + client.assignGuestName(this.getUsernameList()); + status = "1"; + } else + if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) { + client.assignGuestName(this.getUsernameList()); + status = "2"; + } else + if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) { + client.assignGuestName(this.getUsernameList()); + status = "3"; + } else client.username = newName; + } + //@ts-ignore + client.sendMsg(guacutils.encode("rename", "0", status, client.username)); + if (hadName) { + console.log(`[RENAME] ${client.IP} from ${oldname} to ${client.username}`); + this.clients.filter(c => c.username !== client.username).forEach((c) => + //@ts-ignore + c.sendMsg(guacutils.encode("rename", "1", oldname, client.username))); + } else { + console.log(`[RENAME] ${client.IP} to ${client.username}`); + this.clients.forEach((c) => + //@ts-ignore + c.sendMsg(guacutils.encode("adduser", "1", client.username, client.rank))); + } + } + getAdduserMsg() : string { var arr : string[] = ["adduser", this.clients.length.toString()]; //@ts-ignore @@ -309,12 +476,16 @@ export default class WSServer { this.ChatHistory.forEach(c => arr.push(c.user, c.msg)); return guacutils.encode(...arr); } - private sendTurnUpdate() { + private sendTurnUpdate(client? : User) { var turnQueueArr = this.TurnQueue.toArray(); var arr = ["turn", (this.TurnTime * 1000).toString(), this.TurnQueue.size.toString()]; // @ts-ignore this.TurnQueue.forEach((c) => arr.push(c.username)); var currentTurningUser = this.TurnQueue.peek(); + if (client) { + client.sendMsg(guacutils.encode(...arr)); + return; + } this.clients.filter(c => (c !== currentTurningUser && c.connectedToNode)).forEach((c) => { if (turnQueueArr.indexOf(c) !== -1) { var time = ((this.TurnTime * 1000) + ((turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000)); @@ -336,6 +507,27 @@ export default class WSServer { } this.sendTurnUpdate(); } + + clearTurns() { + clearInterval(this.TurnInterval); + this.TurnIntervalRunning = false; + this.TurnQueue.clear(); + this.sendTurnUpdate(); + } + + bypassTurn(client : User) { + var a = this.TurnQueue.toArray().filter(c => c !== client); + this.TurnQueue = Queue.from([client, ...a]); + this.nextTurn(); + } + + endTurn(client : User) { + var hasTurn = (this.TurnQueue.peek() === client); + this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(c => c !== client)); + if (hasTurn) this.nextTurn(); + else this.sendTurnUpdate(); + } + private turnInterval() { this.TurnTime--; if (this.TurnTime < 1) { @@ -364,4 +556,58 @@ export default class WSServer { res(jpg.toString("base64")); }) } + + startVote() { + if (this.voteInProgress) return; + this.voteInProgress = true; + this.clients.forEach(c => c.sendMsg(guacutils.encode("vote", "0"))); + this.voteTime = this.Config.collabvm.voteTime; + this.voteInterval = setInterval(() => { + this.voteTime--; + if (this.voteTime < 1) { + this.endVote(); + } + }, 1000); + } + + endVote(result? : boolean) { + if (!this.voteInProgress) return; + this.voteInProgress = false; + clearInterval(this.voteInterval); + var count = this.getVoteCounts(); + this.clients.forEach((c) => c.sendMsg(guacutils.encode("vote", "2"))); + if (result === true || (result === undefined && count.yes >= count.no)) { + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has won."))); + this.VM.Restore(); + } else { + this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost."))); + } + this.clients.forEach(c => c.vote = null); + this.voteTimeout = 180; + this.voteTimeoutInterval = setInterval(() => { + this.voteTimeout--; + if (this.voteTimeout < 1) + clearInterval(this.voteTimeoutInterval); + }, 1000); + } + + sendVoteUpdate(client? : User) { + if (!this.voteInProgress) return; + var count = this.getVoteCounts(); + var msg = guacutils.encode("vote", "1", (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString()); + if (client) + client.sendMsg(msg); + else + this.clients.forEach((c) => c.sendMsg(msg)); + } + + getVoteCounts() : {yes:number,no:number} { + var yes = 0; + var no = 0; + this.clients.forEach((c) => { + if (c.vote === true) yes++; + if (c.vote === false) no++; + }); + return {yes:yes,no:no}; + } } \ No newline at end of file