Create docker build config

This commit is contained in:
Hri7566 2024-08-13 05:55:27 -04:00
parent 7b124b1d83
commit 78fc178652
29 changed files with 441 additions and 129 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
node_modules
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
.env.template
.gitmodules
.prettierrc
.eslintrc.js

34
Dockerfile Normal file
View File

@ -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", "." ]

View File

@ -66,9 +66,6 @@ This has always been the future intention of this project.
## TODO ## TODO
- Fully implement and test tags
- Tags are sent to clients now
- Check if tags are sent to everyone
- Channel data saving - Channel data saving
- Permission groups and permissions - Permission groups and permissions
- Probable permission groups: owner, admin, mod, trialmod, default - 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 different messages?
- Check for URL? - Check for URL?
- Notifications for server-generated XSS? - 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 ## How to run

BIN
bun.lockb

Binary file not shown.

View File

@ -1,8 +1,11 @@
# Channel config file # Channel config file
# Which channels to keep loaded on startup
forceLoad: forceLoad:
- lobby - lobby
- test/awkward - test/awkward
# Default settings for lobbies
lobbySettings: lobbySettings:
lobby: true lobby: true
chat: true chat: true
@ -10,19 +13,35 @@ lobbySettings:
visible: true visible: true
color: "#73b3cc" color: "#73b3cc"
color2: "#273546" color2: "#273546"
# Default settings for regular channels
defaultSettings: defaultSettings:
chat: true chat: true
crownsolo: false crownsolo: false
color: "#3b5054" color: "#3b5054"
color2: "#001014" color2: "#001014"
visible: true 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: lobbyRegexes:
- ^lobby[0-9][0-9]$ - ^lobby[0-9][0-9]$
- ^lobby[0-9]$ - ^lobby[0-9]$
- ^lobby$ - ^lobby$
- ^lobbyNaN$ - ^lobbyNaN$
- ^test/.+$ - ^test/.+$
# Backdoor channel ID for bypassing the lobby limit
lobbyBackdoor: lolwutsecretlobbybackdoor lobbyBackdoor: lolwutsecretlobbybackdoor
# Channel ID for where you get sent when you join a channel that is full/you get banned/etc
fullChannel: test/awkward fullChannel: test/awkward
# Whether to send the channel limit to the client
sendLimit: false sendLimit: false
# Whether to give the crown to the user who had it when they rejoin
chownOnRejoin: true chownOnRejoin: true
# Time in milliseconds to wait before destroying an empty channel
channelDestroyTimeout: 1000

12
config/prometheus.yml Normal file
View File

@ -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"]

View File

@ -37,14 +37,15 @@ enableAdminEval: true
# The token validation scheme. Valid values are "none", "jwt" and "uuid". # 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 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. # 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". # The browser challenge scheme. Valid options are "none", "obf" and "basic".
# This is to change what is sent in the "b" message. # This is to change what is sent in the "b" message.
# "none" will disable the browser challenge, # "none" will disable the browser challenge,
# "obf" will sent an obfuscated function to the client, # "obf" will sent an obfuscated function to the client,
# and "basic" will just send a simple function that expects a boolean. # 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. # Scheme for generating user IDs.
# Valid options are "random", "sha256", "mpp" and "uuid". # Valid options are "random", "sha256", "mpp" and "uuid".

1
config/util.yml Normal file
View File

@ -0,0 +1 @@
enableLogFiles: true

View File

@ -6,6 +6,10 @@
"keywords": [], "keywords": [],
"author": "Hri7566", "author": "Hri7566",
"license": "ISC", "license": "ISC",
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run src/index.ts --watch"
},
"dependencies": { "dependencies": {
"@prisma/client": "5.17.0", "@prisma/client": "5.17.0",
"@t3-oss/env-core": "^0.6.1", "@t3-oss/env-core": "^0.6.1",
@ -17,6 +21,7 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keccak": "^2.1.0", "keccak": "^2.1.0",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"prom-client": "^15.1.3",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"yaml": "^2.5.0", "yaml": "^2.5.0",
"zod": "^3.23.8" "zod": "^3.23.8"
@ -32,4 +37,4 @@
"prisma": "5.17.0", "prisma": "5.17.0",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

View File

@ -68,7 +68,7 @@ export class Channel extends EventEmitter {
} }
private async save() { private async save() {
this.logger.debug("Saving channel data"); //this.logger.debug("Saving channel data");
try { try {
const info = this.getInfo(); const info = this.getInfo();
@ -78,16 +78,16 @@ export class Channel extends EventEmitter {
flags: JSON.stringify(this.flags) 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); await saveChannel(this.getID(), data);
} catch (err) { } catch (err) {
this.logger.debug("Error saving cannel:", err); this.logger.warn("Error saving channel data:", err);
} }
} }
private async load() { private async load() {
this.logger.debug("Loading saved data"); //this.logger.debug("Loading saved data");
try { try {
const data = await getSavedChannel(this.getID()); const data = await getSavedChannel(this.getID());
if (data) { if (data) {
@ -100,9 +100,11 @@ export class Channel extends EventEmitter {
forceloadChannel(this.getID()); forceloadChannel(this.getID());
} }
this.logger.debug("Loaded channel data:", data); //this.logger.debug("Loaded channel data:", data);
this.emit("update", this);
} catch (err) { } catch (err) {
this.logger.debug("Error loading channel data:", err); this.logger.error("Error loading channel data:", err);
} }
} }
} catch (err) { } } catch (err) { }
@ -125,7 +127,7 @@ export class Channel extends EventEmitter {
) { ) {
super(); super();
this.logger = new Logger("Channel - " + _id); this.logger = new Logger("Channel - " + _id, "logs/channel");
this.settings = {}; this.settings = {};
// Copy default settings // Copy default settings
@ -209,7 +211,13 @@ export class Channel extends EventEmitter {
} }
if (this.ppl.length == 0 && !this.stays) { 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") .replace(/(\p{Mc}{5})\p{Mc}+/gu, "$1")
.trim(); .trim();
const part = socket.getParticipant() as Participant;
let outgoing: ClientEvents["a"] = { let outgoing: ClientEvents["a"] = {
m: "a", m: "a",
a: msg.message, a: msg.message,
t: Date.now(), t: Date.now(),
p: socket.getParticipant() as Participant p: part
}; };
this.sendArray([outgoing]); this.sendArray([outgoing]);
this.chatHistory.push(outgoing); this.chatHistory.push(outgoing);
await saveChatHistory(this.getID(), this.chatHistory); await saveChatHistory(this.getID(), this.chatHistory);
this.logger.info(`${part._id} ${part.name}: ${outgoing.a}`);
if (msg.message.startsWith("/")) { if (msg.message.startsWith("/")) {
this.emit("command", msg, socket); 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 // Set the verified settings
for (const key of Object.keys(validatedSet)) { 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; if ((validatedSet as any)[key] === false) continue;
(this.settings as any)[key] = (set as any)[key]; (this.settings as any)[key] = (set as any)[key];
} }

View File

@ -10,6 +10,7 @@ interface ChannelConfig {
fullChannel: string; fullChannel: string;
sendLimit: boolean; sendLimit: boolean;
chownOnRejoin: boolean; chownOnRejoin: boolean;
channelDestroyTimeout: number;
} }
export const config = loadConfig<ChannelConfig>("config/channels.yml", { export const config = loadConfig<ChannelConfig>("config/channels.yml", {
@ -34,5 +35,6 @@ export const config = loadConfig<ChannelConfig>("config/channels.yml", {
lobbyBackdoor: "lolwutsecretlobbybackdoor", lobbyBackdoor: "lolwutsecretlobbybackdoor",
fullChannel: "test/awkward", fullChannel: "test/awkward",
sendLimit: false, sendLimit: false,
chownOnRejoin: true chownOnRejoin: true,
channelDestroyTimeout: 1000
}); });

View File

@ -1,9 +1,15 @@
/** /**
* MPP Server 2 * MPP Server 2
* for mpp.dev * for https://www.multiplayerpiano.dev/
* by Hri7566 * 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 // There are a lot of unhinged bs comments in this repo
// Pay no attention to the ones that cuss you out // Pay no attention to the ones that cuss you out
@ -11,15 +17,23 @@
import "./ws/server"; import "./ws/server";
import { loadForcedStartupChannels } from "./channel/forceLoad"; import { loadForcedStartupChannels } from "./channel/forceLoad";
import { Logger } from "./util/Logger"; 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 // wrapper for some reason
// and then keep it in memory for the entirety of runtime export function startServer() {
const logger = new Logger("Main"); // Let's construct an entire object just for one thing to be printed
logger.info("Forceloading startup channels..."); // and then keep it in memory for the entirety of runtime
loadForcedStartupChannels(); 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 // Break the console
import "./util/readline"; startReadline();
// Nevermind we use it twice // Nevermind, two things are printed
logger.info("Ready"); logger.info("Ready");
}
startServer();
startMetricsServer();

View File

@ -1,8 +1,19 @@
import EventEmitter from "events"; import EventEmitter from "events";
import { padNum, unimportant } from "./helpers"; 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(); 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 * A logger that doesn't fuck with the readline prompt
* timestamps are likely wrong because of js timezones * timestamps are likely wrong because of js timezones
@ -14,7 +25,7 @@ export class Logger {
* @param method The method from `console` to use * @param method The method from `console` to use
* @param args The data to print * @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 // Un-fuck the readline prompt
process.stdout.write("\x1b[2K\r"); process.stdout.write("\x1b[2K\r");
@ -33,6 +44,23 @@ export class Logger {
// Emit the log event for remote consoles // Emit the log event for remote consoles
logEvents.emit("log", method, unimportant(this.getDate()), unimportant(this.getHHMMSSMS()), args); 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]; 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 * Print an info message
* @param args The data to print * @param args The data to print
**/ **/
public info(...args: any[]) { 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 * @param args The data to print
**/ **/
public error(...args: any[]) { 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 * @param args The data to print
**/ **/
public warn(...args: any[]) { 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 * @param args The data to print
**/ **/
public debug(...args: any[]) { 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);
} }
} }

View File

@ -1,6 +1,5 @@
import { existsSync, readFileSync, writeFileSync } from "fs"; import { existsSync, readFileSync, writeFileSync } from "fs";
import { parse, stringify } from "yaml"; import { parse, stringify } from "yaml";
import { z } from "zod";
/** /**
* This file uses the synchronous functions from the fs * This file uses the synchronous functions from the fs
@ -63,6 +62,7 @@ export function loadConfig<T>(configPath: string, defaultConfig: T): T {
return config as T; return config as T;
} else { } else {
// Write default config to disk and use that // Write default config to disk and use that
//logger.warn(`Config file "${configPath}" not found, writing default config to disk`);
writeConfig(configPath, defaultConfig); writeConfig(configPath, defaultConfig);
return defaultConfig as T; return defaultConfig as T;
} }

View File

@ -35,6 +35,9 @@ export function createUserID(ip: string) {
.update("::ffff:" + ip + env.SALT) .update("::ffff:" + ip + env.SALT)
.digest("hex") .digest("hex")
.substring(0, 24); .substring(0, 24);
} else {
// Fallback if someone typed random garbage in the config
return createID();
} }
} }

37
src/util/metrics.ts Normal file
View File

@ -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<void>) {
(this.callbacks as ((...args: any[]) => void)[]).push(callback);
}
}
function enableMetrics() {
setInterval(() => {
(metrics.callbacks as ((...args: any[]) => void)[]).forEach(callback => callback());
}, 5000);
}

View File

@ -3,23 +3,32 @@ import logger from "./logger";
import Command from "./Command"; import Command from "./Command";
import "./commands"; import "./commands";
export const rl = readline.createInterface({ export let rl: readline.Interface;
input: process.stdin,
output: process.stdout
});
rl.setPrompt("mpps> "); // Turned into a function so the import isn't in a weird spot
rl.prompt(); export function startReadline() {
rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.on("line", async line => { rl.setPrompt("mpps> ");
const out = await Command.handleCommand(line);
logger.info(out);
rl.prompt(); rl.prompt();
});
rl.on("SIGINT", () => { rl.on("line", async line => {
process.exit(); const out = await Command.handleCommand(line);
}); logger.info(out);
rl.prompt();
});
// Fucking cringe but it works rl.on("SIGINT", () => {
(globalThis as unknown as any).rl = rl; process.exit();
});
// Fucking cringe but it works
(globalThis as unknown as any).rl = rl;
}
export function stopReadline() {
rl.close();
}

5
src/util/utilConfig.ts Normal file
View File

@ -0,0 +1,5 @@
import { loadConfig } from "./config";
export const config = loadConfig("config/util.yml", {
enableLogFiles: true
});

View File

@ -11,65 +11,71 @@
* or, you know, maybe I could log their user agent * or, you know, maybe I could log their user agent
* and IP address instead sometime in the future. * and IP address instead sometime in the future.
*/ */
import { Logger } from "../util/Logger";
const logger = new Logger("Socket Gateway");
export class Gateway { export class Gateway {
// Whether we have correctly processed this socket's hi message // 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 // Whether they have sent the MIDI devices message
public hasSentDevices = false; public hasSentDevices = false; // implemented
// Whether they have sent a token // Whether they have sent a token
public hasSentToken = false; public hasSentToken = false; // implemented
// Whether their token is valid // Whether their token is valid
public isTokenValid = false; public isTokenValid = false; // implemented
// Their user agent, if sent // Their user agent, if sent
public userAgent = ""; public userAgent = ""; // TODO
// Whether they have moved their cursor // 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 // 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 // The last time they sent a ping message
public lastPing = Date.now(); public lastPing = Date.now(); // implemented
// Whether they have joined any channel // Whether they have joined any channel
public hasJoinedAnyChannel = false; public hasJoinedAnyChannel = false; // implemented
// Whether they have joined the lobby // Whether they have joined a lobby
public hasJoinedLobby = false; public hasJoinedLobby = false; // implemented
// Whether they have made a regular non-websocket request to the HTTP server // Whether they have made a regular non-websocket request to the HTTP server
// probably useful for checking if they are actually on the site // probably useful for checking if they are actually on the site
// Maybe not useful if cloudflare is being used // Maybe not useful if cloudflare is being used
// In that scenario, templating wouldn't work, either // In that scenario, templating wouldn't work, either
public hasConnectedToHTTPServer = false; public hasConnectedToHTTPServer = false; // implemented
// Various chat message flags // Various chat message flags
public hasSentChatMessage = false; public hasSentChatMessage = false; // implemented
public hasSentChatMessageWithCapitalLettersOnly = false; public hasSentChatMessageWithCapitalLettersOnly = false; // implemented
public hasSentChatMessageWithInvisibleCharacters = false; public hasSentChatMessageWithInvisibleCharacters = false; // implemented
public hasSentChatMessageWithEmoji = false; public hasSentChatMessageWithEmoji = false; // implemented
// Whehter or not the user has played the piano in this session // 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 // 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) // Whether the user has changed their name/color this session (not just changed from default)
public hasChangedName = false; public hasChangedName = false; // implemented
public hasChangedColor = false; public hasChangedColor = false; // implemented
// Whether the user has sent
public hasSentCustomNoteData = false;
// Whether they sent an admin message that was invalid (wrong password, etc) // 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 // Whether or not they have passed the b message
public hasCompletedBrowserChallenge = false; public hasCompletedBrowserChallenge = false; // implemented
public dump() {
return JSON.stringify(this, undefined, 4);
}
} }

View File

@ -146,7 +146,7 @@ export class Socket extends EventEmitter {
// Basic function // Basic function
this.sendArray([{ this.sendArray([{
m: "b", m: "b",
code: `~return true;` code: `~return btoa(JSON.stringify([true, navigator.userAgent]));`
}]); }]);
} else if (config.browserChallenge == "obf") { } else if (config.browserChallenge == "obf") {
// Obfuscated challenge building // 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 _id Target channel ID
* @param set Channel settings, if the channel is instantiated * @param set Channel settings, if the channel is instantiated
* @param force Whether to make this socket join regardless of channel properties * @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 // Make them join the new channel
channel.join(this, force); 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(); public admin = new EventEmitter();

View File

@ -16,6 +16,8 @@ export const plus_ls: ServerEventListener<"+ls"> = {
if (!socket.rateLimits.normal["+ls"].attempt()) return; if (!socket.rateLimits.normal["+ls"].attempt()) return;
} }
socket.gateway.hasOpenedChannelList = true;
socket.subscribeToChannelList(); socket.subscribeToChannelList();
} }
}; };

View File

@ -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"> = { export const a: ServerEventListener<"a"> = {
id: "a", id: "a",
@ -7,9 +31,14 @@ export const a: ServerEventListener<"a"> = {
const flags = socket.getUserFlags(); const flags = socket.getUserFlags();
if (!flags) return; if (!flags) return;
if (typeof msg.message !== "string") return;
// Why did I write this statement so weird // Why did I write this statement so weird
if (!flags["no chat rate limit"] || flags["no chat rate limit"] == 0) if (!flags["no chat rate limit"] || flags["no chat rate limit"] == 0)
if (!socket.rateLimits?.normal.a.attempt()) return; if (!socket.rateLimits?.normal.a.attempt()) return;
populateSocketChatGatewayFlags(msg, socket);
const ch = socket.getCurrentChannel(); const ch = socket.getCurrentChannel();
if (!ch) return; if (!ch) return;

View File

@ -9,8 +9,15 @@ export const admin_message: ServerEventListener<"admin message"> = {
if (socket.rateLimits) if (socket.rateLimits)
if (!socket.rateLimits.normal["admin message"].attempt()) return; if (!socket.rateLimits.normal["admin message"].attempt()) return;
if (typeof msg.password !== "string") return; if (typeof msg.password !== "string") {
if (msg.password !== env.ADMIN_PASS) return; 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 // Probably shouldn't be using password auth in 2024
// Maybe I'll setup a dashboard instead some day // Maybe I'll setup a dashboard instead some day

View File

@ -17,10 +17,20 @@ export const hi: ServerEventListener<"hi"> = {
// Browser challenge // Browser challenge
if (config.browserChallenge == "basic") { 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) { if (arr[0] === true) {
socket.gateway.hasCompletedBrowserChallenge = 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") { } else if (config.browserChallenge == "obf") {
// TODO // TODO
@ -34,11 +44,14 @@ export const hi: ServerEventListener<"hi"> = {
if (config.tokenAuth !== "none") { if (config.tokenAuth !== "none") {
if (typeof msg.token !== "string") { if (typeof msg.token !== "string") {
socket.gateway.hasSentToken = true;
// Get a saved token // Get a saved token
token = await getToken(socket.getUserID()); token = await getToken(socket.getUserID());
if (typeof token !== "string") { if (typeof token !== "string") {
// Generate a new one // Generate a new one
token = await createToken(socket.getUserID(), socket.gateway); token = await createToken(socket.getUserID(), socket.gateway);
socket.gateway.isTokenValid = true;
if (typeof token !== "string") { if (typeof token !== "string") {
logger.warn(`Unable to generate token for user ${socket.getUserID()}`); logger.warn(`Unable to generate token for user ${socket.getUserID()}`);
@ -54,6 +67,7 @@ export const hi: ServerEventListener<"hi"> = {
//return; //return;
} else { } else {
token = msg.token; token = msg.token;
socket.gateway.isTokenValid = true;
} }
} }
} }

View File

@ -13,11 +13,21 @@ export const m: ServerEventListener<"m"> = {
let x = msg.x; let x = msg.x;
let y = msg.y; let y = msg.y;
// Make it numbers // Parse cursor position if it's strings
if (typeof msg.x == "string") x = parseFloat(msg.x); if (typeof msg.x == "string") {
if (typeof msg.y == "string") y = parseFloat(msg.y); 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.setCursorPos(x, y);
socket.gateway.hasCursorMoved = true;
} }
}; };

View File

@ -11,6 +11,8 @@ export const n: ServerEventListener<"n"> = {
if (!Array.isArray(msg.n)) return; if (!Array.isArray(msg.n)) return;
if (typeof msg.t !== "number") return; if (typeof msg.t !== "number") return;
socket.gateway.hasPlayedPianoBefore = true;
// This should've been here months ago // This should've been here months ago
const channel = socket.getCurrentChannel(); const channel = socket.getCurrentChannel();
if (!channel) return; if (!channel) return;

View File

@ -12,6 +12,8 @@ export const t: ServerEventListener<"t"> = {
if (typeof msg.e !== "number") return; if (typeof msg.e !== "number") return;
} }
socket.gateway.lastPing = Date.now();
// Pong // Pong
socket.sendArray([ socket.sendArray([
{ {

View File

@ -1,20 +1,21 @@
import { ServerEventListener } from "../../../../util/types"; import { ServerEventListener } from "../../../../util/types";
import { config } from "../../../usersConfig";
export const userset: ServerEventListener<"userset"> = { export const userset: ServerEventListener<"userset"> = {
id: "userset", id: "userset",
callback: async (msg, socket) => { callback: async (msg, socket) => {
// Change username/color // Change username/color
if (!socket.rateLimits?.chains.userset.attempt()) return; if (!socket.rateLimits?.chains.userset.attempt()) return;
// You can disable color in the config because if (typeof msg.set.name !== "string" && typeof msg.set.color !== "string") return;
// Brandon's/jacored's server doesn't allow color changes,
// and that's the OG server, but folks over at MPP.net if (typeof msg.set.name == "string") {
// said otherwise because they're dumb roleplayers socket.gateway.hasChangedName = true;
// 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... if (typeof msg.set.color == "string" && config.enableColorChanging) {
// Kinda reminds me of crypto. socket.gateway.hasChangedColor = true;
// Also, Brandon's server had color changing on before. }
if (!msg.set.name && !msg.set.color) return;
socket.userset(msg.set.name, msg.set.color); socket.userset(msg.set.name, msg.set.color);
} }
}; };

View File

@ -7,9 +7,14 @@ import { Socket, socketsBySocketID } from "./Socket";
import env from "../util/env"; import env from "../util/env";
import { getMOTD } from "../util/motd"; import { getMOTD } from "../util/motd";
import nunjucks from "nunjucks"; import nunjucks from "nunjucks";
import { metrics } from "../util/metrics";
const logger = new Logger("WebSocket Server"); 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<string, number>();
/** /**
* Get a rendered version of the index file * Get a rendered version of the index file
* @returns Response with html in it * @returns Response with html in it
@ -39,65 +44,81 @@ export const app = Bun.serve<{ ip: string }>({
fetch: (req, server) => { fetch: (req, server) => {
const reqip = server.requestIP(req); const reqip = server.requestIP(req);
if (!reqip) return; if (!reqip) return;
const ip = req.headers.get("x-forwarded-for") || reqip.address; const ip = req.headers.get("x-forwarded-for") || reqip.address;
if ( // Upgrade websocket connections
server.upgrade(req, { if (server.upgrade(req, { data: { ip } })) {
data: {
ip
}
})
) {
return; return;
} else { }
const url = new URL(req.url).pathname;
// lol httpIpCache.set(ip, Date.now());
// const ip = decoder.decode(res.getRemoteAddressAsText()); const url = new URL(req.url).pathname;
// logger.debug(`${req.getMethod()} ${url} ${ip}`);
// res.writeStatus(`200 OK`).end("HI!");
// I have no clue if this is even safe... // lol
// wtf do I do when the user types "/../.env" in the URL? // const ip = decoder.decode(res.getRemoteAddressAsText());
// From my testing, nothing out of the ordinary happens... // logger.debug(`${req.getMethod()} ${url} ${ip}`);
// but just in case, if you find something wrong with URLs, // res.writeStatus(`200 OK`).end("HI!");
// this is the most likely culprit
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 const file = path.join("./public/", url);
try {
if (fs.lstatSync(file).isFile()) {
const data = Bun.file(file);
if (data) { // Time for unreadable blocks of confusion
return new Response(data); try {
} else { // Is it a file?
return getIndex(); if (fs.lstatSync(file).isFile()) {
} // Read the file
const data = Bun.file(file);
// Return the file
if (data) {
return new Response(data);
} else { } else {
return getIndex(); return getIndex();
} }
} catch (err) { } else {
// Return the index file, since it's a channel name or something
return getIndex(); return getIndex();
} }
} catch (err) {
// Return the index file as a coverup of our extreme failure
return getIndex();
} }
}, },
websocket: { websocket: {
open: ws => { open: ws => {
// We got one! // swimming in the pool
const socket = new Socket(ws, createSocketID()); const socket = new Socket(ws, createSocketID());
// Reel 'em in...
(ws as unknown as any).socket = socket; (ws as unknown as any).socket = socket;
// logger.debug("Connection at " + socket.getIP()); // logger.debug("Connection at " + socket.getIP());
// Let's put it in the dinner bucket.
if (socket.socketID == undefined) { if (socket.socketID == undefined) {
socket.socketID = createSocketID(); socket.socketID = createSocketID();
} }
socketsBySocketID.set(socket.socketID, socket); 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) => { message: (ws, message) => {