From f972d4b7b1c6a69ab20cd2aee5a43df702796075 Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 10 Aug 2021 03:22:27 +0300 Subject: Ban/unban commands /ban Name 3d10h20m59s /ban Name 2h30m /unban Name /removeBan Name see #26 --- src/Client.hx | 12 ++++- src/ClientTools.hx | 1 + src/Types.hx | 13 +++++ src/client/Buttons.hx | 1 + src/client/Main.hx | 87 ++++++++++++++++++++++++++------ src/client/Utils.hx | 8 +++ src/server/ConsoleInput.hx | 23 ++++++++- src/server/HttpServer.hx | 3 +- src/server/Main.hx | 120 +++++++++++++++++++++++++++++++++++++++------ 9 files changed, 232 insertions(+), 36 deletions(-) (limited to 'src') diff --git a/src/Client.hx b/src/Client.hx index 254e294..ae35ef4 100644 --- a/src/Client.hx +++ b/src/Client.hx @@ -7,6 +7,7 @@ import js.npm.ws.WebSocket; import haxe.EnumFlags; enum ClientGroup { + Banned; User; Leader; Admin; @@ -20,12 +21,13 @@ typedef ClientData = { class Client { #if nodejs public final ws:WebSocket; - public final id:Int; public final req:IncomingMessage; + public final id:Int; public var isAlive = true; #end public var name:String; public var group:EnumFlags; + public var isBanned(get, set):Bool; public var isUser(get, set):Bool; public var isLeader(get, set):Bool; public var isAdmin(get, set):Bool; @@ -45,6 +47,14 @@ class Client { } #end + inline function get_isBanned():Bool { + return group.has(Banned); + } + + inline function set_isBanned(flag:Bool):Bool { + return setGroupFlag(Banned, flag); + } + inline function get_isUser():Bool { return group.has(User); } diff --git a/src/ClientTools.hx b/src/ClientTools.hx index b4ac9cb..c5053f1 100644 --- a/src/ClientTools.hx +++ b/src/ClientTools.hx @@ -29,6 +29,7 @@ class ClientTools { public static function hasPermission(client:Client, permission:Permission, permissions:Permissions):Bool { final p = permissions; + if (client.isBanned) return p.banned.contains(permission); if (client.isAdmin) return p.admin.contains(permission); if (client.isLeader) return p.leader.contains(permission); if (client.isUser) return p.user.contains(permission); diff --git a/src/Types.hx b/src/Types.hx index 519ac76..c2136b4 100644 --- a/src/Types.hx +++ b/src/Types.hx @@ -36,6 +36,7 @@ typedef Config = { } typedef Permissions = { + banned:Array, guest:Array, user:Array, leader:Array, @@ -56,10 +57,12 @@ enum abstract Permission(String) { var SetLeaderPerm = "setLeader"; var ChangeOrderPerm = "changeOrder"; var LockPlaylistPerm = "lockPlaylist"; + var BanClientPerm = "banClient"; } typedef UserList = { admins:Array, + bans:Array, ?salt:String } @@ -68,6 +71,11 @@ typedef UserField = { hash:String } +typedef BanField = { + ip:String, + toDate:Date +} + typedef Emote = { name:String, image:String @@ -130,6 +138,10 @@ typedef WsEvent = { ?updateClients:{ clients:Array, }, + ?banClient:{ + name:String, + time:Float + }, ?addVideo:{ item:VideoItem, atEnd:Bool @@ -192,6 +204,7 @@ enum abstract WsEventType(String) { var UpdateClients; // var AddClient; // var RemoveClient; + var BanClient; var AddVideo; var RemoveVideo; var SkipVideo; diff --git a/src/client/Buttons.hx b/src/client/Buttons.hx index 86a7d82..aba629e 100644 --- a/src/client/Buttons.hx +++ b/src/client/Buttons.hx @@ -351,6 +351,7 @@ class Buttons { if (Utils.isTouch()) main.scrollChatToEnd(); } new InputWithHistory(chatline, 50, value -> { + if (main.handleCommands(value)) return true; main.send({ type: Message, message: { diff --git a/src/client/Main.hx b/src/client/Main.hx index c5dca67..57a2d0f 100644 --- a/src/client/Main.hx +++ b/src/client/Main.hx @@ -382,8 +382,8 @@ class Main { case Connected: onConnected(data); onTimeGet.run(); - case Disconnected: // server-only + case Disconnected: // server-only case Login: onLogin(data.login.clients, data.login.clientName); @@ -411,6 +411,7 @@ class Main { personal = clients.getByName(personal.name, personal); if (personal.group.toInt() != oldGroup) onUserGroupChanged(); + case BanClient: // server-only case Message: addMessage(data.message.clientName, data.message.text); @@ -728,7 +729,8 @@ class Main { for (client in clients) { list.add('
'); if (client.isLeader) list.add(''); - final klass = client.isAdmin ? "userlist_owner" : ""; + var klass = client.isBanned ? "userlist_banned" : ""; + if (client.isAdmin) klass += " userlist_owner"; list.add('${client.name}
'); } final userlist = ge("#userlist"); @@ -764,12 +766,8 @@ class Main { textDiv.className = "text"; text = text.htmlEscape(); - if (text.startsWith("/")) { - if (name == personal.name) handleCommands(text.substr(1)); - } else { - for (filter in filters) { - text = filter.regex.replace(text, filter.replace); - } + for (filter in filters) { + text = filter.regex.replace(text, filter.replace); } textDiv.innerHTML = text; final isInChatEnd = msgBuf.scrollTop + msgBuf.clientHeight >= msgBuf.scrollHeight - 1; @@ -828,21 +826,80 @@ class Main { msgBuf.scrollTop = msgBuf.scrollHeight; } - final matchNumbers = ~/^-?[0-9]+$/; - - function handleCommands(text:String):Void { - switch (text) { + /* Returns `true` if text should not be sent to chat */ + public function handleCommands(command:String):Bool { + if (!command.startsWith("/")) return false; + final args = command.trim().split(" "); + command = args.shift().substr(1); + + switch (command) { + case "ban": + final name = args[0]; + final time = parseSimpleDate(args[1]); + if (time < 0) return true; + send({ + type: BanClient, + banClient: { + name: name, + time: time + } + }); + return true; + case "unban", "removeBan": + final name = args[0]; + send({ + type: BanClient, + banClient: { + name: name, + time: 0 + } + }); + return true; case "clear": - if (isAdmin()) send({type: ClearChat}); + send({type: ClearChat}); + return true; } - if (matchNumbers.match(text)) { + if (matchSimpleDate.match(command)) { send({ type: Rewind, rewind: { - time: Std.parseInt(text) + time: parseSimpleDate(command) } }); + return false; + } + return false; + } + + final matchSimpleDate = ~/^-?([0-9]+d)?([0-9]+h)?([0-9]+m)?([0-9]+s?)?$/; + + function parseSimpleDate(text:Null):Int { + if (text == null) return 0; + if (!matchSimpleDate.match(text)) return 0; + final matches:Array = []; + final length = Utils.matchedNum(matchSimpleDate); + for (i in 1...length) { + final group = matchSimpleDate.matched(i); + if (group == null) continue; + matches.push(group); + } + var seconds = 0; + for (block in matches) { + seconds += parseSimpleDateBlock(block); + } + if (text.startsWith("-")) seconds = -seconds; + return seconds; + } + + function parseSimpleDateBlock(block:String):Int { + inline function time():Int { + return Std.parseInt(block.substr(0, block.length - 1)); } + if (block.endsWith("s")) return time(); + else if (block.endsWith("m")) return time() * 60; + else if (block.endsWith("h")) return time() * 60 * 60; + else if (block.endsWith("d")) return time() * 60 * 60 * 24; + return Std.parseInt(block); } public function blinkTabWithTitle(title:String):Void { diff --git a/src/client/Utils.hx b/src/client/Utils.hx index 232ca02..f23365e 100644 --- a/src/client/Utils.hx +++ b/src/client/Utils.hx @@ -89,6 +89,14 @@ class Utils { } } + public static function matchedNum(ereg:EReg):Int { + #if js + return (ereg : Dynamic).r.m.length; + #else + #error "not implemented" + #end + } + public static function browseFileUrl( onFileLoad:(url:String, name:String) -> Void, isBinary = true, diff --git a/src/server/ConsoleInput.hx b/src/server/ConsoleInput.hx index 9b8faf3..119d87e 100644 --- a/src/server/ConsoleInput.hx +++ b/src/server/ConsoleInput.hx @@ -18,6 +18,7 @@ private typedef CommandData = { private enum abstract Command(String) from String { var AddAdmin = "addAdmin"; + var RemoveAdmin = "removeAdmin"; var Replay = "replay"; var LogList = "logList"; var Exit = "exit"; @@ -30,6 +31,10 @@ class ConsoleInput { args: ["name", "password"], desc: "Adds channel admin" }, + RemoveAdmin => { + args: ["name"], + desc: "Removes channel admin" + }, Replay => { args: ["name"], desc: "Replay log file on server from user/logs/" @@ -54,10 +59,10 @@ class ConsoleInput { output: process.stdout, completer: onCompletion }); - haxe.Log.trace = (msg, ?pos) -> { + haxe.Log.trace = (msg:Dynamic, ?infos:haxe.PosInfos) -> { Readline.clearLine(process.stdout, 0); Readline.cursorTo(process.stdout, 0, null); - Console.log(msg); + Console.log(formatOutput(msg, infos)); rl.prompt(true); }; rl.prompt(); @@ -68,6 +73,16 @@ class ConsoleInput { // rl.on("close", exit); } + function formatOutput(v:Dynamic, infos:haxe.PosInfos):String { + var str = Std.string(v); + if (infos == null) return str; + if (infos.customParams != null) { + for (v in infos.customParams) + str += ", " + Std.string(v); + } + return str; + } + function onCompletion(line:String):Array, String>> { final commands:Array = [ for (item in commands.keys()) '/$item ' @@ -102,6 +117,10 @@ class ConsoleInput { } main.addAdmin(name, password); + case RemoveAdmin: + final name = args[0]; + main.removeAdmin(name); + case Replay: Utils.ensureDir(main.logsDir); final name = args[0]; diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx index 44e4f36..2076336 100644 --- a/src/server/HttpServer.hx +++ b/src/server/HttpServer.hx @@ -58,8 +58,7 @@ class HttpServer { res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Content-Type", getMimeType(ext)); - if (allowLocalRequests - && req.connection.remoteAddress == req.connection.localAddress + if (allowLocalRequests && req.socket.remoteAddress == req.socket.localAddress || allowedLocalFiles[url]) { if (isMediaExtension(ext)) { allowedLocalFiles[url] = true; diff --git a/src/server/Main.hx b/src/server/Main.hx index ea1e40b..2f3a8a4 100644 --- a/src/server/Main.hx +++ b/src/server/Main.hx @@ -196,16 +196,32 @@ class Main { function loadUsers():UserList { final customPath = '$rootDir/user/users.json'; if (!FileSystem.exists(customPath)) return { - admins: [] + admins: [], + bans: [] }; - return Json.parse(File.getContent(customPath)); + final users:UserList = Json.parse(File.getContent(customPath)); + if (users.admins == null) users.admins = []; + if (users.bans == null) users.bans = []; + for (field in users.bans) { + field.toDate = Date.fromString(cast field.toDate); + } + return users; } function writeUsers(users:UserList):Void { final folder = '$rootDir/user'; Utils.ensureDir(folder); - final data = Json.stringify(users, "\t"); - File.saveContent('$folder/users.json', data); + final data:UserList = { + admins: users.admins, + bans: [ + for (field in users.bans) { + ip: field.ip, + toDate: cast field.toDate.toString() + } + ], + salt: users.salt + } + File.saveContent('$folder/users.json', Json.stringify(data, "\t")); } function saveState():Void { @@ -222,6 +238,7 @@ class Main { } final json = Json.stringify(data, "\t"); File.saveContent(statePath, json); + writeUsers(userList); } function loadState():Void { @@ -251,10 +268,12 @@ class Main { File.saveContent('$crashesFolder/$name.json', Json.stringify(data, "\t")); } + var isHeroku = false; + function initIntergationHandlers():Void { + isHeroku = process.env["_"] != null && process.env["_"].contains("heroku"); // Prevent heroku idle when clients online (needs APP_URL env var) - if (process.env["_"] != null && process.env["_"].contains("heroku") - && process.env["APP_URL"] != null) { + if (isHeroku && process.env["APP_URL"] != null) { var url = process.env["APP_URL"]; if (!url.startsWith("http")) url = 'http://$url'; new Timer(10 * 60 * 1000).run = function() { @@ -265,18 +284,34 @@ class Main { } } + function clientIp(req:IncomingMessage):String { + // Heroku uses internal proxy, so header cannot be spoofed + if (isHeroku) { + var forwarded:String = req.headers["x-forwarded-for"]; + forwarded = forwarded.split(",")[0].trim(); + if (forwarded == null || forwarded.length == 0) return req.socket.remoteAddress; + return forwarded; + } + return req.socket.remoteAddress; + } + public function addAdmin(name:String, password:String):Void { password += config.salt; final hash = Sha256.encode(password); - if (userList.admins == null) userList.admins = []; userList.admins.push({ name: name, hash: hash }); - writeUsers(userList); trace('Admin $name added.'); } + public function removeAdmin(name:String):Void { + userList.admins.remove( + userList.admins.find(item -> item.name == name) + ); + trace('Admin $name removed.'); + } + public function replayLog(events:Array):Void { final timer = new Timer(1000); timer.run = () -> { @@ -309,11 +344,11 @@ class Main { } function onConnect(ws:WebSocket, req:IncomingMessage):Void { - final ip = req.connection.remoteAddress; + final ip = clientIp(req); final id = freeIds.length > 0 ? freeIds.shift() : clients.length; final name = 'Guest ${id + 1}'; trace('$name connected ($ip)'); - final isAdmin = config.localAdmins && req.connection.localAddress == ip; + final isAdmin = config.localAdmins && req.socket.localAddress == ip; final client = new Client(ws, req, id, name, 0); client.isAdmin = isAdmin; clients.push(client); @@ -366,6 +401,7 @@ class Main { if (videoTimer.isPaused()) videoTimer.play(); } + checkBan(client); send(client, { type: Connected, connected: { @@ -407,6 +443,34 @@ class Main { case UpdateClients: sendClientList(); + + case BanClient: + if (!checkPermission(client, BanClientPerm)) return; + final name = data.banClient.name; + final bannedClient = clients.getByName(name); + if (bannedClient == null) return; + if (client.name == name || bannedClient.isAdmin) { + serverMessage(client, "adminsCannotBeBannedError"); + return; + } + final ip = clientIp(bannedClient.req); + userList.bans.remove(userList.bans.find(item -> item.ip == ip)); + if (data.banClient.time == 0) { + bannedClient.isBanned = false; + sendClientList(); + return; + } + final currentTime = Date.now().getTime(); + final time = currentTime + data.banClient.time * 1000; + if (time < currentTime) return; + userList.bans.push({ + ip: ip, + toDate: Date.fromTime(time) + }); + checkBan(bannedClient); + serverMessage(client, '${bannedClient.name} ($ip) has been banned.'); + sendClientList(); + case Login: final name = data.login.clientName.trim(); final lcName = name.toLowerCase(); @@ -434,6 +498,7 @@ class Main { } client.name = name; client.isUser = true; + checkBan(client); send(client, { type: data.type, login: { @@ -750,16 +815,39 @@ class Main { } function checkPermission(client:Client, perm:Permission):Bool { + if (client.isBanned) checkBan(client); final state = client.hasPermission(perm, config.permissions); - if (!state) send(client, { - type: ServerMessage, - serverMessage: { - textId: "accessError" - } - }); + if (!state) { + send(client, { + type: ServerMessage, + serverMessage: { + textId: "accessError" + } + }); + } return state; } + function checkBan(client:Client):Void { + if (client.isAdmin) { + client.isBanned = false; + return; + } + final ip = clientIp(client.req); + final currentTime = Date.now().getTime(); + for (ban in userList.bans) { + if (ban.ip != ip) continue; + final isOutdated = ban.toDate.getTime() < currentTime; + client.isBanned = !isOutdated; + if (isOutdated) { + userList.bans.remove(ban); + trace('${client.name} ban removed'); + sendClientList(); + } + break; + } + } + final matchHtmlChars = ~/[&^<>'"]/; final matchGuestName = ~/guest [0-9]+/; -- cgit v1.2.3