diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..beaace2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +.env.template +.gitmodules +.prettierrc +.eslintrc.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..436da2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM oven/bun:latest AS base +WORKDIR /usr/src/app + +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +RUN mkdir -p /temp/prod +COPY package.json bun.lockb /temp/prod +RUN cd /temp/prod && bun install --frozen-lockfile --production + +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +ENV NODE_ENV=production +#RUN bun test +#RUN bun build + +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/src/ ./src +COPY --from=prerelease /usr/src/app/package.json . +COPY --from=prerelease /usr/src/app/config ./config +COPY --from=prerelease /usr/src/app/public ./public +COPY --from=prerelease /usr/src/app/mppkey ./mppkey +COPY --from=prerelease /usr/src/app/tsconfig.json . +COPY --from=prerelease /usr/src/app/prisma ./prisma +COPY --from=prerelease /usr/src/app/.env . + +USER bun +EXPOSE 8443/tcp +ENTRYPOINT [ "bun", "." ] diff --git a/README.md b/README.md index 2f37d81..f0c0f6f 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,6 @@ This has always been the future intention of this project. ## TODO -- Fully implement and test tags - - Tags are sent to clients now - - Check if tags are sent to everyone - Channel data saving - Permission groups and permissions - Probable permission groups: owner, admin, mod, trialmod, default @@ -104,6 +101,12 @@ This has always been the future intention of this project. - Check for different messages? - Check for URL? - Notifications for server-generated XSS? +- Migrate to PostgreSQL instead of SQLite + - Likely a low priority, we use prisma anyway, but it would be nice to have a server +- Implement user caching + - Skip redis due to the infamous licensing issues + - Probably use a simple in-memory cache + - Likely store with leveldb or JSON ## How to run diff --git a/bun.lockb b/bun.lockb index bb2cffc..a666ad2 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/config/channels.yml b/config/channels.yml index 81674d0..ac2e97d 100644 --- a/config/channels.yml +++ b/config/channels.yml @@ -1,8 +1,11 @@ # Channel config file +# Which channels to keep loaded on startup forceLoad: - lobby - test/awkward + +# Default settings for lobbies lobbySettings: lobby: true chat: true @@ -10,19 +13,35 @@ lobbySettings: visible: true color: "#73b3cc" color2: "#273546" + +# Default settings for regular channels defaultSettings: chat: true crownsolo: false color: "#3b5054" color2: "#001014" visible: true + +# Regexes to match against channel names to determine whether they are lobbies or not +# This doesn't affect the `isRealLobby` function, which is used to determine "classic" lobbies lobbyRegexes: - ^lobby[0-9][0-9]$ - ^lobby[0-9]$ - ^lobby$ - ^lobbyNaN$ - ^test/.+$ + +# Backdoor channel ID for bypassing the lobby limit lobbyBackdoor: lolwutsecretlobbybackdoor + +# Channel ID for where you get sent when you join a channel that is full/you get banned/etc fullChannel: test/awkward + +# Whether to send the channel limit to the client sendLimit: false + +# Whether to give the crown to the user who had it when they rejoin chownOnRejoin: true + +# Time in milliseconds to wait before destroying an empty channel +channelDestroyTimeout: 1000 diff --git a/config/prometheus.yml b/config/prometheus.yml new file mode 100644 index 0000000..6687145 --- /dev/null +++ b/config/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: prometheus + scrape_interval: "5s" + static_configs: + - targets: ["localhost:9090"] + + - job_name: mpp + static_configs: + - targets: ["192.168.1.24:9100"] diff --git a/config/users.yml b/config/users.yml index 6e2012f..383fedd 100644 --- a/config/users.yml +++ b/config/users.yml @@ -37,14 +37,15 @@ enableAdminEval: true # The token validation scheme. Valid values are "none", "jwt" and "uuid". # This server will still validate existing tokens generated with other schemes if not set to "none", mimicking MPP.net's server. # This is set to "none" by default because MPP.com does not have a token system. -tokenAuth: none +tokenAuth: jwt # The browser challenge scheme. Valid options are "none", "obf" and "basic". # This is to change what is sent in the "b" message. # "none" will disable the browser challenge, # "obf" will sent an obfuscated function to the client, # and "basic" will just send a simple function that expects a boolean. -browserChallenge: none +# FIXME Note that "obf" is not implemented yet, and has undefined behavior. +browserChallenge: basic # Scheme for generating user IDs. # Valid options are "random", "sha256", "mpp" and "uuid". diff --git a/config/util.yml b/config/util.yml new file mode 100644 index 0000000..19dd417 --- /dev/null +++ b/config/util.yml @@ -0,0 +1 @@ +enableLogFiles: true diff --git a/package.json b/package.json index 8891890..6e61e92 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,10 @@ "keywords": [], "author": "Hri7566", "license": "ISC", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun run src/index.ts --watch" + }, "dependencies": { "@prisma/client": "5.17.0", "@t3-oss/env-core": "^0.6.1", @@ -17,6 +21,7 @@ "jsonwebtoken": "^9.0.2", "keccak": "^2.1.0", "nunjucks": "^3.2.4", + "prom-client": "^15.1.3", "unique-names-generator": "^4.7.1", "yaml": "^2.5.0", "zod": "^3.23.8" @@ -32,4 +37,4 @@ "prisma": "5.17.0", "typescript": "^5.5.4" } -} \ No newline at end of file +} diff --git a/src/channel/Channel.ts b/src/channel/Channel.ts index d1c5b34..045a18b 100644 --- a/src/channel/Channel.ts +++ b/src/channel/Channel.ts @@ -68,7 +68,7 @@ export class Channel extends EventEmitter { } private async save() { - this.logger.debug("Saving channel data"); + //this.logger.debug("Saving channel data"); try { const info = this.getInfo(); @@ -78,16 +78,16 @@ export class Channel extends EventEmitter { flags: JSON.stringify(this.flags) }; - this.logger.debug("Channel data to save:", data); + //this.logger.debug("Channel data to save:", data); await saveChannel(this.getID(), data); } catch (err) { - this.logger.debug("Error saving cannel:", err); + this.logger.warn("Error saving channel data:", err); } } private async load() { - this.logger.debug("Loading saved data"); + //this.logger.debug("Loading saved data"); try { const data = await getSavedChannel(this.getID()); if (data) { @@ -100,9 +100,11 @@ export class Channel extends EventEmitter { forceloadChannel(this.getID()); } - this.logger.debug("Loaded channel data:", data); + //this.logger.debug("Loaded channel data:", data); + + this.emit("update", this); } catch (err) { - this.logger.debug("Error loading channel data:", err); + this.logger.error("Error loading channel data:", err); } } } catch (err) { } @@ -125,7 +127,7 @@ export class Channel extends EventEmitter { ) { super(); - this.logger = new Logger("Channel - " + _id); + this.logger = new Logger("Channel - " + _id, "logs/channel"); this.settings = {}; // Copy default settings @@ -209,7 +211,13 @@ export class Channel extends EventEmitter { } if (this.ppl.length == 0 && !this.stays) { - this.destroy(); + if (config.channelDestroyTimeout) { + setTimeout(() => { + this.destroy(); + }, config.channelDestroyTimeout); + } else { + this.destroy(); + } } }); @@ -246,17 +254,21 @@ export class Channel extends EventEmitter { .replace(/(\p{Mc}{5})\p{Mc}+/gu, "$1") .trim(); + const part = socket.getParticipant() as Participant; + let outgoing: ClientEvents["a"] = { m: "a", a: msg.message, t: Date.now(), - p: socket.getParticipant() as Participant + p: part }; this.sendArray([outgoing]); this.chatHistory.push(outgoing); await saveChatHistory(this.getID(), this.chatHistory); + this.logger.info(`${part._id} ${part.name}: ${outgoing.a}`); + if (msg.message.startsWith("/")) { this.emit("command", msg, socket); } @@ -373,6 +385,10 @@ export class Channel extends EventEmitter { } ]); }); + + this.on("set owner id", id => { + this.setFlag("owner_id", id); + }); } /** @@ -450,7 +466,7 @@ export class Channel extends EventEmitter { // Set the verified settings for (const key of Object.keys(validatedSet)) { - this.logger.debug(`${key}: ${(validatedSet as any)[key]}`); + //this.logger.debug(`${key}: ${(validatedSet as any)[key]}`); if ((validatedSet as any)[key] === false) continue; (this.settings as any)[key] = (set as any)[key]; } diff --git a/src/channel/config.ts b/src/channel/config.ts index 8fbc82f..63563bb 100644 --- a/src/channel/config.ts +++ b/src/channel/config.ts @@ -10,6 +10,7 @@ interface ChannelConfig { fullChannel: string; sendLimit: boolean; chownOnRejoin: boolean; + channelDestroyTimeout: number; } export const config = loadConfig("config/channels.yml", { @@ -34,5 +35,6 @@ export const config = loadConfig("config/channels.yml", { lobbyBackdoor: "lolwutsecretlobbybackdoor", fullChannel: "test/awkward", sendLimit: false, - chownOnRejoin: true + chownOnRejoin: true, + channelDestroyTimeout: 1000 }); diff --git a/src/index.ts b/src/index.ts index de0060b..ee61822 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,15 @@ /** * MPP Server 2 - * for mpp.dev - * by Hri7566 + * for https://www.multiplayerpiano.dev/ + * Written by Hri7566 + * This code is licensed under the GNU General Public License v3.0. + * Please see `./LICENSE` for more information. */ +/** + * Main entry point for the server + **/ + // There are a lot of unhinged bs comments in this repo // Pay no attention to the ones that cuss you out @@ -11,15 +17,23 @@ import "./ws/server"; import { loadForcedStartupChannels } from "./channel/forceLoad"; import { Logger } from "./util/Logger"; +import { startReadline } from "./util/readline"; +import { startMetricsServer } from "./util/metrics"; -// Let's construct an entire object just for one thing to be printed -// and then keep it in memory for the entirety of runtime -const logger = new Logger("Main"); -logger.info("Forceloading startup channels..."); -loadForcedStartupChannels(); +// wrapper for some reason +export function startServer() { + // Let's construct an entire object just for one thing to be printed + // and then keep it in memory for the entirety of runtime + const logger = new Logger("Main"); + logger.info("Forceloading startup channels..."); + loadForcedStartupChannels(); -// This literally breaks editors and they stick all the imports here instead of at the top -import "./util/readline"; + // Break the console + startReadline(); -// Nevermind we use it twice -logger.info("Ready"); + // Nevermind, two things are printed + logger.info("Ready"); +} + +startServer(); +startMetricsServer(); diff --git a/src/util/Logger.ts b/src/util/Logger.ts index d07c7e5..670d286 100644 --- a/src/util/Logger.ts +++ b/src/util/Logger.ts @@ -1,8 +1,19 @@ import EventEmitter from "events"; import { padNum, unimportant } from "./helpers"; +import { join } from "path"; +import { existsSync, mkdirSync, appendFile, writeFile } from "fs"; +import { config } from "./utilConfig"; export const logEvents = new EventEmitter(); +const logFolder = "./logs"; +// https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python +const logRegex = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; + +if (config.enableLogFiles) { + if (!existsSync(logFolder)) mkdirSync(logFolder); +} + /** * A logger that doesn't fuck with the readline prompt * timestamps are likely wrong because of js timezones @@ -14,7 +25,7 @@ export class Logger { * @param method The method from `console` to use * @param args The data to print **/ - private static log(method: string, ...args: any[]) { + private static log(method: string, logPath: string, ...args: any[]) { // Un-fuck the readline prompt process.stdout.write("\x1b[2K\r"); @@ -33,6 +44,23 @@ export class Logger { // Emit the log event for remote consoles logEvents.emit("log", method, unimportant(this.getDate()), unimportant(this.getHHMMSSMS()), args); + + if (config.enableLogFiles) { + // Write to file + (async () => { + const orig = unimportant(this.getDate()) + " " + unimportant(this.getHHMMSSMS()) + " " + args.join(" ") + "\n" + const text = orig.replace(logRegex, ""); + if (!existsSync(logPath)) { + writeFile(logPath, text, (err) => { + if (err) console.error(err); + }); + } else { + appendFile(logPath, text, (err) => { + if (err) console.error(err); + }); + } + })(); + } } /** @@ -62,14 +90,19 @@ export class Logger { return new Date().toISOString().split("T")[0]; } - constructor(public id: string) { } + public logPath: string; + + constructor(public id: string, logdir: string = logFolder) { + if (!existsSync(logdir)) mkdirSync(logdir); + this.logPath = join(logdir, `${encodeURIComponent(this.id)}.log`); + } /** * Print an info message * @param args The data to print **/ public info(...args: any[]) { - Logger.log("log", `[${this.id}]`, `\x1b[34m[info]\x1b[0m`, ...args); + Logger.log("log", this.logPath, `[${this.id}]`, `\x1b[34m[info]\x1b[0m`, ...args); } /** @@ -77,7 +110,7 @@ export class Logger { * @param args The data to print **/ public error(...args: any[]) { - Logger.log("error", `[${this.id}]`, `\x1b[31m[error]\x1b[0m`, ...args); + Logger.log("error", this.logPath, `[${this.id}]`, `\x1b[31m[error]\x1b[0m`, ...args); } /** @@ -85,7 +118,7 @@ export class Logger { * @param args The data to print **/ public warn(...args: any[]) { - Logger.log("warn", `[${this.id}]`, `\x1b[33m[warn]\x1b[0m`, ...args); + Logger.log("warn", this.logPath, `[${this.id}]`, `\x1b[33m[warn]\x1b[0m`, ...args); } /** @@ -93,6 +126,6 @@ export class Logger { * @param args The data to print **/ public debug(...args: any[]) { - Logger.log("debug", `[${this.id}]`, `\x1b[32m[debug]\x1b[0m`, ...args); + Logger.log("debug", this.logPath, `[${this.id}]`, `\x1b[32m[debug]\x1b[0m`, ...args); } } diff --git a/src/util/config.ts b/src/util/config.ts index 8ede627..8eb8fb5 100644 --- a/src/util/config.ts +++ b/src/util/config.ts @@ -1,6 +1,5 @@ import { existsSync, readFileSync, writeFileSync } from "fs"; import { parse, stringify } from "yaml"; -import { z } from "zod"; /** * This file uses the synchronous functions from the fs @@ -63,6 +62,7 @@ export function loadConfig(configPath: string, defaultConfig: T): T { return config as T; } else { // Write default config to disk and use that + //logger.warn(`Config file "${configPath}" not found, writing default config to disk`); writeConfig(configPath, defaultConfig); return defaultConfig as T; } diff --git a/src/util/id.ts b/src/util/id.ts index 714b4fc..3939ade 100644 --- a/src/util/id.ts +++ b/src/util/id.ts @@ -35,6 +35,9 @@ export function createUserID(ip: string) { .update("::ffff:" + ip + env.SALT) .digest("hex") .substring(0, 24); + } else { + // Fallback if someone typed random garbage in the config + return createID(); } } diff --git a/src/util/metrics.ts b/src/util/metrics.ts new file mode 100644 index 0000000..1171c0c --- /dev/null +++ b/src/util/metrics.ts @@ -0,0 +1,37 @@ +import client, { Registry } from "prom-client"; +import { Logger } from "./Logger"; + +const logger = new Logger("Metrics Server"); + +export function startMetricsServer() { + client.collectDefaultMetrics(); + logger.info("Starting Prometheus metrics server..."); + + const server = Bun.serve({ + port: 9100, + async fetch(req) { + const res = new Response(await client.register.metrics()); + res.headers.set("Content-Type", client.register.contentType); + return res; + } + }); + + enableMetrics(); +} + +export const metrics = { + concurrentUsers: new client.Histogram({ + name: "concurrent_users", + help: "Number of concurrent users", + }), + callbacks: [], + addCallback(callback: (...args: any[]) => void | Promise) { + (this.callbacks as ((...args: any[]) => void)[]).push(callback); + } +} + +function enableMetrics() { + setInterval(() => { + (metrics.callbacks as ((...args: any[]) => void)[]).forEach(callback => callback()); + }, 5000); +} diff --git a/src/util/readline/index.ts b/src/util/readline/index.ts index d68ca78..c57528b 100644 --- a/src/util/readline/index.ts +++ b/src/util/readline/index.ts @@ -3,23 +3,32 @@ import logger from "./logger"; import Command from "./Command"; import "./commands"; -export const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); +export let rl: readline.Interface; -rl.setPrompt("mpps> "); -rl.prompt(); +// Turned into a function so the import isn't in a weird spot +export function startReadline() { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); -rl.on("line", async line => { - const out = await Command.handleCommand(line); - logger.info(out); + rl.setPrompt("mpps> "); rl.prompt(); -}); -rl.on("SIGINT", () => { - process.exit(); -}); + rl.on("line", async line => { + const out = await Command.handleCommand(line); + logger.info(out); + rl.prompt(); + }); -// Fucking cringe but it works -(globalThis as unknown as any).rl = rl; + rl.on("SIGINT", () => { + process.exit(); + }); + + // Fucking cringe but it works + (globalThis as unknown as any).rl = rl; +} + +export function stopReadline() { + rl.close(); +} diff --git a/src/util/utilConfig.ts b/src/util/utilConfig.ts new file mode 100644 index 0000000..85b8ec1 --- /dev/null +++ b/src/util/utilConfig.ts @@ -0,0 +1,5 @@ +import { loadConfig } from "./config"; + +export const config = loadConfig("config/util.yml", { + enableLogFiles: true +}); diff --git a/src/ws/Gateway.ts b/src/ws/Gateway.ts index afb373f..b6f945a 100644 --- a/src/ws/Gateway.ts +++ b/src/ws/Gateway.ts @@ -11,65 +11,71 @@ * or, you know, maybe I could log their user agent * and IP address instead sometime in the future. */ + +import { Logger } from "../util/Logger"; + +const logger = new Logger("Socket Gateway"); + export class Gateway { // Whether we have correctly processed this socket's hi message - public hasProcessedHi = false; + public hasProcessedHi = false; // implemented // Whether they have sent the MIDI devices message - public hasSentDevices = false; + public hasSentDevices = false; // implemented // Whether they have sent a token - public hasSentToken = false; + public hasSentToken = false; // implemented // Whether their token is valid - public isTokenValid = false; + public isTokenValid = false; // implemented // Their user agent, if sent - public userAgent = ""; + public userAgent = ""; // TODO // Whether they have moved their cursor - public hasCursorMoved = false; + public hasCursorMoved = false; // implemented // Whether they sent a cursor message that contained numbers instead of stringified numbers - public isCursorNotString = false; + public isCursorNotString = false; // implemented // The last time they sent a ping message - public lastPing = Date.now(); + public lastPing = Date.now(); // implemented // Whether they have joined any channel - public hasJoinedAnyChannel = false; + public hasJoinedAnyChannel = false; // implemented - // Whether they have joined the lobby - public hasJoinedLobby = false; + // Whether they have joined a lobby + public hasJoinedLobby = false; // implemented // Whether they have made a regular non-websocket request to the HTTP server // probably useful for checking if they are actually on the site // Maybe not useful if cloudflare is being used // In that scenario, templating wouldn't work, either - public hasConnectedToHTTPServer = false; + public hasConnectedToHTTPServer = false; // implemented // Various chat message flags - public hasSentChatMessage = false; - public hasSentChatMessageWithCapitalLettersOnly = false; - public hasSentChatMessageWithInvisibleCharacters = false; - public hasSentChatMessageWithEmoji = false; + public hasSentChatMessage = false; // implemented + public hasSentChatMessageWithCapitalLettersOnly = false; // implemented + public hasSentChatMessageWithInvisibleCharacters = false; // implemented + public hasSentChatMessageWithEmoji = false; // implemented // Whehter or not the user has played the piano in this session - public hasPlayedPianoBefore = false; + public hasPlayedPianoBefore = false; // implemented // Whether the user has sent a channel list subscription request, a.k.a. opened the channel list - public hasOpenedChannelList = false; + public hasOpenedChannelList = false; // implemented // Whether the user has changed their name/color this session (not just changed from default) - public hasChangedName = false; - public hasChangedColor = false; - - // Whether the user has sent - public hasSentCustomNoteData = false; + public hasChangedName = false; // implemented + public hasChangedColor = false; // implemented // Whether they sent an admin message that was invalid (wrong password, etc) - public hasSentInvalidAdminMessage = false; + public hasSentInvalidAdminMessage = false; // implemented // Whether or not they have passed the b message - public hasCompletedBrowserChallenge = false; + public hasCompletedBrowserChallenge = false; // implemented + + public dump() { + return JSON.stringify(this, undefined, 4); + } } diff --git a/src/ws/Socket.ts b/src/ws/Socket.ts index d3f456c..96ae5f9 100644 --- a/src/ws/Socket.ts +++ b/src/ws/Socket.ts @@ -146,7 +146,7 @@ export class Socket extends EventEmitter { // Basic function this.sendArray([{ m: "b", - code: `~return true;` + code: `~return btoa(JSON.stringify([true, navigator.userAgent]));` }]); } else if (config.browserChallenge == "obf") { // Obfuscated challenge building @@ -183,7 +183,7 @@ export class Socket extends EventEmitter { } /** - * Move this participant to a channel + * Move this socket to a channel * @param _id Target channel ID * @param set Channel settings, if the channel is instantiated * @param force Whether to make this socket join regardless of channel properties @@ -239,6 +239,17 @@ export class Socket extends EventEmitter { // Make them join the new channel channel.join(this, force); } + + // Gateway stuff + this.gateway.hasJoinedAnyChannel = true; + + const ch = this.getCurrentChannel(); + + if (ch) { + if (ch.isLobby()) { + this.gateway.hasJoinedLobby = true; + } + } } public admin = new EventEmitter(); diff --git a/src/ws/events/user/handlers/+ls.ts b/src/ws/events/user/handlers/+ls.ts index d9f47af..d595f03 100644 --- a/src/ws/events/user/handlers/+ls.ts +++ b/src/ws/events/user/handlers/+ls.ts @@ -16,6 +16,8 @@ export const plus_ls: ServerEventListener<"+ls"> = { if (!socket.rateLimits.normal["+ls"].attempt()) return; } + socket.gateway.hasOpenedChannelList = true; + socket.subscribeToChannelList(); } }; diff --git a/src/ws/events/user/handlers/a.ts b/src/ws/events/user/handlers/a.ts index e14ffdd..08c6dcb 100644 --- a/src/ws/events/user/handlers/a.ts +++ b/src/ws/events/user/handlers/a.ts @@ -1,4 +1,28 @@ -import { ServerEventListener } from "../../../../util/types"; +import { Socket } from "../../../Socket"; +import { ServerEventListener, ServerEvents } from "../../../../util/types"; + +// https://stackoverflow.com/questions/64509631/is-there-a-regex-to-match-all-unicode-emojis +const emojiRegex = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g; + +function populateSocketChatGatewayFlags(msg: ServerEvents["a"], socket: Socket) { + socket.gateway.hasSentChatMessage = true; + + if (msg.message.toUpperCase() == msg.message) { + socket.gateway.hasSentChatMessageWithCapitalLettersOnly = true; + } + + if (msg.message.includes("\u034f") || msg.message.includes("\u200b")) { + socket.gateway.hasSentChatMessageWithInvisibleCharacters = true; + } + + if (msg.message.match(/[^\x00-\x7f]/gm)) { + socket.gateway.hasSentChatMessageWithInvisibleCharacters = true; + } + + if (msg.message.match(emojiRegex)) { + socket.gateway.hasSentChatMessageWithEmoji = true; + } +} export const a: ServerEventListener<"a"> = { id: "a", @@ -7,9 +31,14 @@ export const a: ServerEventListener<"a"> = { const flags = socket.getUserFlags(); if (!flags) return; + if (typeof msg.message !== "string") return; + // Why did I write this statement so weird if (!flags["no chat rate limit"] || flags["no chat rate limit"] == 0) if (!socket.rateLimits?.normal.a.attempt()) return; + + populateSocketChatGatewayFlags(msg, socket); + const ch = socket.getCurrentChannel(); if (!ch) return; diff --git a/src/ws/events/user/handlers/admin_message.ts b/src/ws/events/user/handlers/admin_message.ts index eb44d47..7e0c00a 100644 --- a/src/ws/events/user/handlers/admin_message.ts +++ b/src/ws/events/user/handlers/admin_message.ts @@ -9,8 +9,15 @@ export const admin_message: ServerEventListener<"admin message"> = { if (socket.rateLimits) if (!socket.rateLimits.normal["admin message"].attempt()) return; - if (typeof msg.password !== "string") return; - if (msg.password !== env.ADMIN_PASS) return; + if (typeof msg.password !== "string") { + socket.gateway.hasSentInvalidAdminMessage = true; + return; + } + + if (msg.password !== env.ADMIN_PASS) { + socket.gateway.hasSentInvalidAdminMessage = true; + return; + } // Probably shouldn't be using password auth in 2024 // Maybe I'll setup a dashboard instead some day diff --git a/src/ws/events/user/handlers/hi.ts b/src/ws/events/user/handlers/hi.ts index 56fa878..236f20e 100644 --- a/src/ws/events/user/handlers/hi.ts +++ b/src/ws/events/user/handlers/hi.ts @@ -17,10 +17,20 @@ export const hi: ServerEventListener<"hi"> = { // Browser challenge if (config.browserChallenge == "basic") { - if (typeof msg.code !== "boolean") return; + try { + if (typeof msg.code !== "string") return; + const code = atob(msg.code); + const arr = JSON.parse(code); - if (msg.code === true) { - socket.gateway.hasCompletedBrowserChallenge = true; + if (arr[0] === true) { + socket.gateway.hasCompletedBrowserChallenge = true; + + if (typeof arr[1] === "string") { + socket.gateway.userAgent = arr[1]; + } + } + } catch (err) { + logger.warn("Unable to parse basic browser challenge code:", err); } } else if (config.browserChallenge == "obf") { // TODO @@ -34,11 +44,14 @@ export const hi: ServerEventListener<"hi"> = { if (config.tokenAuth !== "none") { if (typeof msg.token !== "string") { + socket.gateway.hasSentToken = true; + // Get a saved token token = await getToken(socket.getUserID()); if (typeof token !== "string") { // Generate a new one token = await createToken(socket.getUserID(), socket.gateway); + socket.gateway.isTokenValid = true; if (typeof token !== "string") { logger.warn(`Unable to generate token for user ${socket.getUserID()}`); @@ -54,6 +67,7 @@ export const hi: ServerEventListener<"hi"> = { //return; } else { token = msg.token; + socket.gateway.isTokenValid = true; } } } diff --git a/src/ws/events/user/handlers/m.ts b/src/ws/events/user/handlers/m.ts index 5e6dc8b..6086751 100644 --- a/src/ws/events/user/handlers/m.ts +++ b/src/ws/events/user/handlers/m.ts @@ -13,11 +13,21 @@ export const m: ServerEventListener<"m"> = { let x = msg.x; let y = msg.y; - // Make it numbers - if (typeof msg.x == "string") x = parseFloat(msg.x); - if (typeof msg.y == "string") y = parseFloat(msg.y); + // Parse cursor position if it's strings + if (typeof msg.x == "string") { + x = parseFloat(msg.x); + } else { + socket.gateway.isCursorNotString = true; + } - // Move the laggy piece of shit + if (typeof msg.y == "string") { + y = parseFloat(msg.y); + } else { + socket.gateway.isCursorNotString = true; + } + + // Relocate the laggy microscopic speck socket.setCursorPos(x, y); + socket.gateway.hasCursorMoved = true; } }; diff --git a/src/ws/events/user/handlers/n.ts b/src/ws/events/user/handlers/n.ts index 66c83eb..0dcc102 100644 --- a/src/ws/events/user/handlers/n.ts +++ b/src/ws/events/user/handlers/n.ts @@ -11,6 +11,8 @@ export const n: ServerEventListener<"n"> = { if (!Array.isArray(msg.n)) return; if (typeof msg.t !== "number") return; + socket.gateway.hasPlayedPianoBefore = true; + // This should've been here months ago const channel = socket.getCurrentChannel(); if (!channel) return; diff --git a/src/ws/events/user/handlers/t.ts b/src/ws/events/user/handlers/t.ts index 92dfe20..9e9c372 100644 --- a/src/ws/events/user/handlers/t.ts +++ b/src/ws/events/user/handlers/t.ts @@ -12,6 +12,8 @@ export const t: ServerEventListener<"t"> = { if (typeof msg.e !== "number") return; } + socket.gateway.lastPing = Date.now(); + // Pong socket.sendArray([ { diff --git a/src/ws/events/user/handlers/userset.ts b/src/ws/events/user/handlers/userset.ts index 601063c..5555499 100644 --- a/src/ws/events/user/handlers/userset.ts +++ b/src/ws/events/user/handlers/userset.ts @@ -1,20 +1,21 @@ import { ServerEventListener } from "../../../../util/types"; +import { config } from "../../../usersConfig"; export const userset: ServerEventListener<"userset"> = { id: "userset", callback: async (msg, socket) => { // Change username/color if (!socket.rateLimits?.chains.userset.attempt()) return; - // You can disable color in the config because - // Brandon's/jacored's server doesn't allow color changes, - // and that's the OG server, but folks over at MPP.net - // said otherwise because they're dumb roleplayers - // or something and don't understand the unique value - // of the fishing bot and how it allows you to change colors - // without much control, giving it the feeling of value... - // Kinda reminds me of crypto. - // Also, Brandon's server had color changing on before. - if (!msg.set.name && !msg.set.color) return; + if (typeof msg.set.name !== "string" && typeof msg.set.color !== "string") return; + + if (typeof msg.set.name == "string") { + socket.gateway.hasChangedName = true; + } + + if (typeof msg.set.color == "string" && config.enableColorChanging) { + socket.gateway.hasChangedColor = true; + } + socket.userset(msg.set.name, msg.set.color); } }; diff --git a/src/ws/server.ts b/src/ws/server.ts index 66e4934..f7aefc6 100644 --- a/src/ws/server.ts +++ b/src/ws/server.ts @@ -7,9 +7,14 @@ import { Socket, socketsBySocketID } from "./Socket"; import env from "../util/env"; import { getMOTD } from "../util/motd"; import nunjucks from "nunjucks"; +import { metrics } from "../util/metrics"; const logger = new Logger("WebSocket Server"); +// ip -> timestamp +// for checking if they visited the site and are also connected to the websocket +const httpIpCache = new Map(); + /** * Get a rendered version of the index file * @returns Response with html in it @@ -39,65 +44,81 @@ export const app = Bun.serve<{ ip: string }>({ fetch: (req, server) => { const reqip = server.requestIP(req); if (!reqip) return; + const ip = req.headers.get("x-forwarded-for") || reqip.address; - if ( - server.upgrade(req, { - data: { - ip - } - }) - ) { + // Upgrade websocket connections + if (server.upgrade(req, { data: { ip } })) { return; - } else { - const url = new URL(req.url).pathname; + } - // lol - // const ip = decoder.decode(res.getRemoteAddressAsText()); - // logger.debug(`${req.getMethod()} ${url} ${ip}`); - // res.writeStatus(`200 OK`).end("HI!"); + httpIpCache.set(ip, Date.now()); + const url = new URL(req.url).pathname; - // I have no clue if this is even safe... - // wtf do I do when the user types "/../.env" in the URL? - // From my testing, nothing out of the ordinary happens... - // but just in case, if you find something wrong with URLs, - // this is the most likely culprit + // lol + // const ip = decoder.decode(res.getRemoteAddressAsText()); + // logger.debug(`${req.getMethod()} ${url} ${ip}`); + // res.writeStatus(`200 OK`).end("HI!"); - const file = path.join("./public/", url); + // I have no clue if this is even safe... + // wtf do I do when the user types "/../.env" in the URL? + // From my testing, nothing out of the ordinary happens... + // but just in case, if you find something wrong with URLs, + // this is the most likely culprit - // Time for unreadable blocks of confusion - try { - if (fs.lstatSync(file).isFile()) { - const data = Bun.file(file); + const file = path.join("./public/", url); - if (data) { - return new Response(data); - } else { - return getIndex(); - } + // Time for unreadable blocks of confusion + try { + // Is it a file? + if (fs.lstatSync(file).isFile()) { + // Read the file + const data = Bun.file(file); + + // Return the file + if (data) { + return new Response(data); } else { return getIndex(); } - } catch (err) { + } else { + // Return the index file, since it's a channel name or something return getIndex(); } + } catch (err) { + // Return the index file as a coverup of our extreme failure + return getIndex(); } }, websocket: { open: ws => { - // We got one! + // swimming in the pool const socket = new Socket(ws, createSocketID()); - // Reel 'em in... (ws as unknown as any).socket = socket; // logger.debug("Connection at " + socket.getIP()); - // Let's put it in the dinner bucket. if (socket.socketID == undefined) { socket.socketID = createSocketID(); } socketsBySocketID.set(socket.socketID, socket); + + const ip = socket.getIP(); + + if (httpIpCache.has(ip)) { + const date = httpIpCache.get(ip); + + if (date) { + if (Date.now() - date < 1000 * 60) { + // They got the page and we were connected in under a minute + socket.gateway.hasConnectedToHTTPServer = true; + } else { + // They got the page and a long time has passed + httpIpCache.delete(ip); + } + } + } }, message: (ws, message) => {