diff --git a/msagent.js/src/agent.ts b/msagent.js/src/agent.ts index 1b5d415..ab6b631 100644 --- a/msagent.js/src/agent.ts +++ b/msagent.js/src/agent.ts @@ -1,5 +1,6 @@ import { BufferStream, SeekDir } from './buffer.js'; import { AcsData } from './character.js'; +import { ContextMenu, ContextMenuItem } from './contextmenu.js'; import { AcsAnimation, AcsAnimationFrameInfo } from './structs/animation.js'; import { AcsImageEntry } from './structs/image.js'; import { Point, Size } from './types.js'; @@ -69,7 +70,7 @@ class AgentWordBalloonState { balloonCanvas: HTMLCanvasElement; balloonCanvasCtx: CanvasRenderingContext2D; - constructor(char: Agent, text: string, hasTip: boolean, position: AgentWordBalloonPosition) { + constructor(char: Agent, text: string, hasTip: boolean, position: AgentWordBalloonPosition, textColor: string) { this.char = char; this.text = text; this.hasTip = hasTip; @@ -87,13 +88,13 @@ class AgentWordBalloonState { // hack fix for above this.balloonCanvas.style.pointerEvents = 'none'; - let rect = wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip); + let rect = wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip, textColor); // Second pass, actually set the element to the right width and stuffs this.balloonCanvas.width = rect.w; this.balloonCanvas.height = rect.h; - wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip); + wordballoonDrawText(this.balloonCanvasCtx, { x: 0, y: 0 }, this.text, 20, hasTip, textColor); this.char.getElement().appendChild(this.balloonCanvas); @@ -142,6 +143,8 @@ export class Agent { private wordballoonState: AgentWordBalloonState | null = null; private usernameBalloonState: AgentWordBalloonState | null = null; + private contextMenu: ContextMenu; + constructor(data: AcsData) { this.data = data; this.charDiv = document.createElement('div'); @@ -155,6 +158,8 @@ export class Agent { this.cnv.height = data.characterInfo.charHeight; this.cnv.style.display = 'none'; + this.contextMenu = new ContextMenu(this.charDiv); + this.charDiv.appendChild(this.cnv); this.dragging = false; @@ -173,7 +178,7 @@ export class Agent { }); this.cnv.addEventListener('contextmenu', (e) => { e.preventDefault(); - // TODO: Custom context menu support + this.contextMenu.show(e.clientX, e.clientY); }); document.addEventListener('mousemove', (e) => { if (!this.dragging) return; @@ -201,6 +206,10 @@ export class Agent { return this.charDiv; } + getContextMenu() { + return this.contextMenu; + } + getAt() { let point: Point = { x: this.x, @@ -284,13 +293,13 @@ export class Agent { if (index !== -1) this.playAnimation(index, finishCallback); } - setUsername(username: string) { + setUsername(username: string, color: string) { if (this.usernameBalloonState !== null) { this.usernameBalloonState.finish(); this.usernameBalloonState = null; } - this.usernameBalloonState = new AgentWordBalloonState(this, username, false, AgentWordBalloonPosition.BelowCentered); + this.usernameBalloonState = new AgentWordBalloonState(this, username, false, AgentWordBalloonPosition.BelowCentered, color); this.usernameBalloonState.show(); } @@ -299,7 +308,7 @@ export class Agent { this.stopSpeaking(); } - this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered); + this.wordballoonState = new AgentWordBalloonState(this, text, true, AgentWordBalloonPosition.AboveCentered, "#000000"); this.wordballoonState.positionUpdated(); this.wordballoonState.show(); } diff --git a/msagent.js/src/contextmenu.ts b/msagent.js/src/contextmenu.ts new file mode 100644 index 0000000..5c3cecf --- /dev/null +++ b/msagent.js/src/contextmenu.ts @@ -0,0 +1,80 @@ +export class ContextMenuItem { + private element: HTMLLIElement; + + name: string; + cb: Function; + + constructor(name: string, cb: Function) { + this.name = name; + this.cb = cb; + this.element = document.createElement("li"); + this.element.classList.add("context-menu-item"); + this.element.innerText = name; + this.element.addEventListener('mousedown', () => this.cb()); + } + + getElement() { + return this.element; + } + + setName(name: string) { + this.name = name; + this.element.innerText = name; + } + + setCb(cb: Function) { + this.cb = cb; + } +} + +export class ContextMenu { + private element: HTMLDivElement; + private list: HTMLUListElement; + + private items: Array + + constructor(parent: HTMLElement) { + this.element = document.createElement("div"); + this.list = document.createElement("ul"); + this.element.appendChild(this.list); + this.items = []; + this.element.classList.add("context-menu"); + this.element.style.display = "none"; + this.element.style.position = "fixed"; + parent.appendChild(this.element); + } + + show(x: number, y: number) { + this.element.style.left = x + "px"; + this.element.style.top = y + "px"; + document.addEventListener('mousedown', () => { + this.hide(); + }, {once: true}); + this.element.style.display = "block"; + } + + hide() { + this.element.style.display = "none"; + } + + addItem(item: ContextMenuItem) { + this.items.push(item); + this.list.appendChild(item.getElement()); + } + + removeItem(item: ContextMenuItem) { + let i = this.items.indexOf(item); + if (i === -1) return; + this.items.splice(i, 1); + item.getElement().remove(); + } + + getItem(name: string) { + return this.items.find(i => i.name === name); + } + + clearItems() { + this.items.splice(0, this.items.length); + this.list.replaceChildren(); + } +} \ No newline at end of file diff --git a/msagent.js/src/index.ts b/msagent.js/src/index.ts index 3d3c4e0..38c4c64 100644 --- a/msagent.js/src/index.ts +++ b/msagent.js/src/index.ts @@ -6,6 +6,7 @@ export * from "./character.js"; export * from "./decompress.js"; export * from "./sprite.js"; export * from "./wordballoon.js"; +export * from "./contextmenu.js"; // Convinence function which initalizes all of msagent.js. diff --git a/msagent.js/src/wordballoon.ts b/msagent.js/src/wordballoon.ts index 1e25c97..ec2e4f9 100644 --- a/msagent.js/src/wordballoon.ts +++ b/msagent.js/src/wordballoon.ts @@ -129,7 +129,7 @@ function wordWrapToStringList(text: string, maxLength: number) { } // This draws a wordballoon with text. This function respects the current context's font settings and does *not* modify them. -export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, text: string, maxLen: number = 20, hasTip: boolean = true): Rect { +export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, text: string, maxLen: number = 20, hasTip: boolean = true, color: string = "#000000"): Rect { let lines = wordWrapToStringList(text, maxLen); // Create metrics for each line @@ -167,6 +167,7 @@ export function wordballoonDrawText(ctx: CanvasRenderingContext2D, at: Point, te let metric = metrics[i]; let height = metric.actualBoundingBoxAscent + metric.actualBoundingBoxDescent; + ctx.fillStyle = color; ctx.fillText(lines[i], rectInner.x - 12, rectInner.y + y); y += height * 1.25; } diff --git a/protocol/src/admin.ts b/protocol/src/admin.ts new file mode 100644 index 0000000..fa1ca98 --- /dev/null +++ b/protocol/src/admin.ts @@ -0,0 +1,64 @@ +import { MSAgentProtocolMessage, MSAgentProtocolMessageType } from "./protocol"; + +export enum MSAgentAdminOperation { + // Client-to-server + Kick = "kick", + Ban = "ban", + // Bidirectional + Login = "login", + GetIP = "ip", +} + +export interface MSAgentAdminMessage extends MSAgentProtocolMessage { + op: MSAgentProtocolMessageType.Admin, + data: { + action: MSAgentAdminOperation + } +} + +// Client-to-server + +export interface MSAgentAdminLoginMessage extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.Login, + password: string + } +} + +export interface MSAgentAdminGetIPMessage extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.GetIP, + username: string + } +} + +export interface MSAgentAdminKickMessage extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.Kick, + username: string + } +} + +export interface MSAgentAdminBanMessage extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.Ban, + username: string + } +} + +// Server-to-client + +export interface MSAgentAdminLoginResponse extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.Login, + success: boolean + } +} + +export interface MSAgentAdminGetIPResponse extends MSAgentAdminMessage { + data: { + action: MSAgentAdminOperation.GetIP, + username: string + ip: string + } +} \ No newline at end of file diff --git a/protocol/src/protocol.ts b/protocol/src/protocol.ts index 1298611..a2e5d9a 100644 --- a/protocol/src/protocol.ts +++ b/protocol/src/protocol.ts @@ -1,12 +1,17 @@ +export * from './admin.js'; + export enum MSAgentProtocolMessageType { // Client-to-server Join = "join", Talk = "talk", + Admin = "admin", // Server-to-client Init = "init", AddUser = "adduser", RemoveUser = "remuser", - Chat = "chat" + Chat = "chat", + Promote = "promote", + Error = "error" } export interface MSAgentProtocolMessage { @@ -67,4 +72,18 @@ export interface MSAgentChatMessage extends MSAgentProtocolMessage { message: string; audio? : string | undefined; } +} + +export interface MSAgentPromoteMessage extends MSAgentProtocolMessage { + op: MSAgentProtocolMessageType.Promote, + data: { + username: string; + } +} + +export interface MSAgentErrorMessage extends MSAgentProtocolMessage { + op: MSAgentProtocolMessageType.Error, + data: { + error: string; + } } \ No newline at end of file diff --git a/server/config.example.toml b/server/config.example.toml index cc968f7..e99eefa 100644 --- a/server/config.example.toml +++ b/server/config.example.toml @@ -3,10 +3,17 @@ host = "127.0.0.1" port = 3000 proxied = true +[mysql] +host = "127.0.0.1" +username = "agentchat" +password = "hunter2" +database = "agentchat" + [chat] charlimit = 100 agentsDir = "agents/" maxConnectionsPerIP = 2 +adminPasswordHash = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7" [chat.ratelimits] chat = {seconds = 10, limit = 8} diff --git a/server/package.json b/server/package.json index e5d4679..dcab1a8 100644 --- a/server/package.json +++ b/server/package.json @@ -15,6 +15,7 @@ "@fastify/websocket": "^10.0.1", "fastify": "^4.28.1", "html-entities": "^2.5.2", + "mysql2": "^3.10.2", "toml": "^3.0.0", "ws": "^8.17.1" }, diff --git a/server/src/client.ts b/server/src/client.ts index f61ffd9..75fd8f0 100644 --- a/server/src/client.ts +++ b/server/src/client.ts @@ -1,9 +1,10 @@ import EventEmitter from "events"; import { WebSocket } from "ws"; -import { MSAgentJoinMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentTalkMessage } from '@msagent-chat/protocol'; +import { MSAgentAdminBanMessage, MSAgentAdminGetIPMessage, MSAgentAdminGetIPResponse, MSAgentAdminKickMessage, MSAgentAdminLoginMessage, MSAgentAdminLoginResponse, MSAgentAdminMessage, MSAgentAdminOperation, MSAgentErrorMessage, MSAgentJoinMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentTalkMessage } from '@msagent-chat/protocol'; import { MSAgentChatRoom } from "./room.js"; import * as htmlentities from 'html-entities'; import RateLimiter from "./ratelimiter.js"; +import { createHash } from "crypto"; // Event types @@ -19,6 +20,7 @@ export class Client extends EventEmitter { ip: string; username: string | null; agent: string | null; + admin: boolean; room: MSAgentChatRoom; socket: WebSocket; @@ -32,6 +34,7 @@ export class Client extends EventEmitter { this.room = room; this.username = null; this.agent = null; + this.admin = false; this.chatRateLimit = new RateLimiter(this.room.config.ratelimits.chat); @@ -64,7 +67,7 @@ export class Client extends EventEmitter { }); } - private parseMessage(data: string) { + private async parseMessage(data: string) { let msg: MSAgentProtocolMessage; try { msg = JSON.parse(data); @@ -105,6 +108,83 @@ export class Client extends EventEmitter { this.emit('talk', talkMsg.data.msg); break; } + case MSAgentProtocolMessageType.Admin: { + let adminMsg = msg as MSAgentAdminMessage; + if (!adminMsg.data) return; + switch (adminMsg.data.action) { + case MSAgentAdminOperation.Login: { + let loginMsg = adminMsg as MSAgentAdminLoginMessage; + if (this.admin || !loginMsg.data.password) return; + let sha256 = createHash("sha256"); + sha256.update(loginMsg.data.password); + let hash = sha256.digest("hex"); + sha256.destroy(); + let success = false; + if (hash === this.room.config.adminPasswordHash) { + this.admin = true; + success = true; + this.emit('admin'); + } + let res : MSAgentAdminLoginResponse = { + op: MSAgentProtocolMessageType.Admin, + data: { + action: MSAgentAdminOperation.Login, + success + } + } + this.send(res); + break; + } + case MSAgentAdminOperation.GetIP: { + let getIPMsg = adminMsg as MSAgentAdminGetIPMessage; + if (!this.admin || !getIPMsg.data || !getIPMsg.data.username) return; + let _user = this.room.clients.find(c => c.username === getIPMsg.data.username); + if (!_user) return; + let res: MSAgentAdminGetIPResponse = { + op: MSAgentProtocolMessageType.Admin, + data: { + action: MSAgentAdminOperation.GetIP, + username: _user.username!, + ip: _user.ip + } + }; + this.send(res); + break; + } + case MSAgentAdminOperation.Kick: { + let kickMsg = adminMsg as MSAgentAdminKickMessage; + if (!this.admin || !kickMsg.data || !kickMsg.data.username) return; + let _user = this.room.clients.find(c => c.username === kickMsg.data.username); + if (!_user) return; + let res: MSAgentErrorMessage = { + op: MSAgentProtocolMessageType.Error, + data: { + error: "You have been kicked." + } + }; + await _user.send(res); + _user.socket.close(); + break; + } + case MSAgentAdminOperation.Ban: { + let banMsg = adminMsg as MSAgentAdminBanMessage; + if (!this.admin || !banMsg.data || !banMsg.data.username) return; + let _user = this.room.clients.find(c => c.username === banMsg.data.username); + if (!_user) return; + let res: MSAgentErrorMessage = { + op: MSAgentProtocolMessageType.Error, + data: { + error: "You have been banned." + } + }; + await this.room.db.banUser(_user.ip, _user.username!); + await _user.send(res); + _user.socket.close(); + break; + } + } + break; + } } } } \ No newline at end of file diff --git a/server/src/config.ts b/server/src/config.ts index 3918d87..f6ab63f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -4,6 +4,7 @@ export interface IConfig { port: number; proxied: boolean; } + mysql: MySQLConfig; chat: ChatConfig; tts: TTSConfig; agents: AgentConfig[]; @@ -21,6 +22,7 @@ export interface ChatConfig { charlimit: number; agentsDir: string; maxConnectionsPerIP: number; + adminPasswordHash: string; ratelimits: { chat: RateLimitConfig; } @@ -32,8 +34,14 @@ export interface AgentConfig { } - export interface RateLimitConfig { seconds: number; limit: number; +} + +export interface MySQLConfig { + host: string; + username: string; + password: string; + database: string; } \ No newline at end of file diff --git a/server/src/database.ts b/server/src/database.ts new file mode 100644 index 0000000..04237aa --- /dev/null +++ b/server/src/database.ts @@ -0,0 +1,38 @@ +import { MySQLConfig } from "./config.js"; +import * as mysql from 'mysql2/promise'; + +export class Database { + private config: MySQLConfig; + private db: mysql.Pool; + + constructor(config: MySQLConfig) { + this.config = config; + this.db = mysql.createPool({ + host: this.config.host, + user: this.config.username, + password: this.config.password, + database: this.config.database, + connectionLimit: 10, + multipleStatements: false + }); + } + + async init() { + let conn = await this.db.getConnection(); + await conn.execute("CREATE TABLE IF NOT EXISTS bans (ip VARCHAR(45) NOT NULL PRIMARY KEY, username TEXT NOT NULL, time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP());"); + conn.release(); + } + + async banUser(ip: string, username: string) { + let conn = await this.db.getConnection(); + await conn.execute("INSERT INTO bans (ip, username) VALUES (?, ?)", [ip, username]); + conn.release(); + } + + async isUserBanned(ip: string): Promise { + let conn = await this.db.getConnection(); + let res = await conn.query("SELECT COUNT(ip) AS cnt FROM bans WHERE ip = ?", [ip]) as mysql.RowDataPacket; + conn.release(); + return res[0][0]["cnt"] !== 0; + } +} \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index 143e83c..8014cc9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,6 +10,8 @@ import { TTSClient } from './tts.js'; import path from 'path'; import { fileURLToPath } from 'url'; import { isIP } from 'net'; +import { Database } from './database.js'; +import { MSAgentErrorMessage, MSAgentProtocolMessageType } from '@msagent-chat/protocol'; let config: IConfig; let configPath: string; @@ -31,6 +33,9 @@ try { process.exit(1); } +let db = new Database(config.mysql); +await db.init(); + const app = Fastify({ logger: true, }); @@ -77,10 +82,10 @@ app.get("/api/agents", (req, res) => { return config.agents; }); -let room = new MSAgentChatRoom(config.chat, config.agents, tts); +let room = new MSAgentChatRoom(config.chat, config.agents, db, tts); app.register(async app => { - app.get("/api/socket", {websocket: true}, (socket, req) => { + app.get("/api/socket", {websocket: true}, async (socket, req) => { // TODO: Do this pre-upgrade and return the appropriate status codes let ip: string; if (config.http.proxied) { @@ -102,6 +107,18 @@ app.register(async app => { } else { ip = req.ip; } + if (await db.isUserBanned(ip)) { + let msg: MSAgentErrorMessage = { + op: MSAgentProtocolMessageType.Error, + data: { + error: "You have been banned." + } + } + socket.send(JSON.stringify(msg), () => { + socket.close(); + }); + return; + } let o = room.clients.filter(c => c.ip === ip); if (o.length >= config.chat.maxConnectionsPerIP) { o[0].socket.close(); diff --git a/server/src/room.ts b/server/src/room.ts index a589f6d..2885e03 100644 --- a/server/src/room.ts +++ b/server/src/room.ts @@ -1,8 +1,9 @@ -import { MSAgentAddUserMessage, MSAgentChatMessage, MSAgentInitMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentRemoveUserMessage } from "@msagent-chat/protocol"; +import { MSAgentAddUserMessage, MSAgentChatMessage, MSAgentInitMessage, MSAgentPromoteMessage, MSAgentProtocolMessage, MSAgentProtocolMessageType, MSAgentRemoveUserMessage } from "@msagent-chat/protocol"; import { Client } from "./client.js"; import { TTSClient } from "./tts.js"; import { AgentConfig, ChatConfig } from "./config.js"; import * as htmlentities from 'html-entities'; +import { Database } from "./database.js"; export class MSAgentChatRoom { agents: AgentConfig[]; @@ -10,12 +11,14 @@ export class MSAgentChatRoom { tts: TTSClient | null; msgId : number = 0; config: ChatConfig; + db: Database; - constructor(config: ChatConfig, agents: AgentConfig[], tts: TTSClient | null) { + constructor(config: ChatConfig, agents: AgentConfig[], db: Database, tts: TTSClient | null) { this.agents = agents; this.clients = []; this.config = config; this.tts = tts; + this.db = db; } addClient(client: Client) { @@ -76,6 +79,17 @@ export class MSAgentChatRoom { _client.send(msg); } }); + client.on('admin', () => { + let msg: MSAgentPromoteMessage = { + op: MSAgentProtocolMessageType.Promote, + data: { + username: client.username! + } + }; + for (const _client of this.getActiveClients()) { + _client.send(msg); + } + }); } private getActiveClients() { diff --git a/webapp/src/css/style.css b/webapp/src/css/style.css index 7f2e26a..bf0d5d8 100644 --- a/webapp/src/css/style.css +++ b/webapp/src/css/style.css @@ -80,4 +80,33 @@ body { #chatSentBtn { font-weight: bold; margin-right: 4px; +} + +.context-menu { + background-color: silver; + ul { + list-style: none; + padding: 0; + margin: 0; + } +} + +.context-menu-item { + padding-right: 0.2rem; + padding-left: 0.2rem; + font-size: 11px; + + padding-bottom: 0.2rem; + padding-top: 0.2rem; + + margin-top: 0.3rem; + margin-bottom: 0.3rem; + font-family: "Pixelated MS Sans Serif", Arial; + + cursor: pointer; +} + +.context-menu-item:hover { + background-color: navy; + color: #fff; } \ No newline at end of file diff --git a/webapp/src/html/index.html b/webapp/src/html/index.html index ef60bd8..2c32e93 100644 --- a/webapp/src/html/index.html +++ b/webapp/src/html/index.html @@ -29,7 +29,7 @@
- +