things + stff

This commit is contained in:
Kim Ravn Hansen
2025-09-07 23:24:50 +02:00
parent fb915f2681
commit 8a4eb25507
27 changed files with 1991 additions and 630 deletions

4
server/utils/config.js Normal file
View File

@@ -0,0 +1,4 @@
export const ENV = process.env.NODE_ENV || "prod";
export const DEV = ENV === "dev";
export const PROD =!DEV;
export const PORT = process.env.PORT || 3000;

View File

@@ -5,45 +5,73 @@
* or
* Server-->Client-->Plater
*/
export const MSG_CALAMITY = "calamity";
export const CALAMITY = "calamity";
/** Tell recipient that an error has occurred */
export const MSG_ERROR = "e";
/**
* Tell recipient that an error has occurred
*
* Server-->Client-->Player
*/
export const ERROR = "e";
/**
* Message to be displayed.
*
* Server-->Client-->Player
*/
export const MSG_MESSAGE = "m";
export const MESSAGE = "m";
/**
* Message contains the player's password (or hash or whatever).
* Player has entered data, and sends it to server.
*
* Player-->Client-->Server
*/
export const MSG_PASSWORD = "pass";
export const REPLY = "reply";
/**
* Player wants to quit.
*
* Player-->Client-->Server
*/
export const QUIT = "quit";
/**
* Player wants help
*
* Player-->Client-->Server
*/
export const HELP = "help";
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*
* Server tells the client to prompt the player for some info
*/
export const MSG_PROMPT = "ask";
/**
* Client sends the player's username to the server
*
* Player-->Client-->Server
*/
export const MSG_USERNAME = "user";
export const PROMPT = "prompt";
/**
* Player has entered a command, and wants to do something.
*
* Player-->Client-->Server
*/
export const MSG_COMMAND = "c";
export const COMMAND = "c";
/**
* Server tells the client to prompt the player for some data
*
* Server-->Client-->Player
*/
export const SYSTEM = "_";
/**
* Debug message, to be completely ignored in production
*
* Client-->Server
* or
* Server-->Client-->Plater
*/
export const DEBUG = "dbg";
/**
* Represents a message sent from client to server.
@@ -53,16 +81,16 @@ export class ClientMessage {
* @protected
* @type {any[]} _arr The array that contains the message data
*/
_arr;
_attr;
/** The message type.
*
* One of the MSG_* constants from this document.
* One of the * constants from this document.
*
* @returns {string}
*/
get type() {
return this._arr[0];
return this._attr[0];
}
/**
@@ -70,66 +98,101 @@ export class ClientMessage {
*/
constructor(msgData) {
if (typeof msgData !== "string") {
throw new Error(
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
typeof msgData,
);
throw new Error("Could not create client message. Attempting to parse json, but data was not even a string, it was a " + typeof msgData);
return;
}
try {
this._arr = JSON.parse(msgData);
this._attr = JSON.parse(msgData);
} catch (_) {
throw new Error(
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
);
throw new Error(`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`);
}
if (typeof this._arr !== "array") {
throw new Error(
`Could not create client message. Excpected an array, but got a ${typeof this._arr}`,
);
if (!Array.isArray(this._attr)) {
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._attr}`);
}
if (this._arr.length < 1) {
throw new Error(
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
);
if (this._attr.length < 1) {
throw new Error("Could not create client message. Excpected an array with at least 1 element, but got an empty one");
}
this._arr = arr;
}
/** Does this message contain a message that should be displayed to the user the "normal" way? */
isMessage() {
return this._arr[0] === "m";
hasCommand() {
return this._attr.length > 1 && this._attr[0] === COMMAND;
}
/** Does this message contain a username-response from the client? */
hasUsername() {
return this._arr[0] === MSG_USERNAME;
isUsernameResponse() {
return this._attr.length === 3
&& this._attr[0] === REPLY
&& this._attr[1] === "username"
&& typeof this._attr[2] === "string";
}
/** Does this message contain a password-response from the client? */
hasPassword() {
return this._arr[0] === MSG_PASSWORD;
isPasswordResponse() {
return this._attr.length === 3
&& this._attr[0] === REPLY
&& this._attr[1] === "password"
&& typeof this._attr[2] === "string";
}
/** @returns {boolean} does this message indicate the player wants to quit */
isQuitCommand() {
return this._attr[0] === QUIT
}
isHelpCommand() {
return this._attr[0] === HELP
}
/** @returns {boolean} is this a debug message? */
isDebug() {
return this._attr.length == 2 && this._attr[0] === DEBUG;
}
isIntegerResponse() {
return this._attr.length === 3
&& this._attr[0] === REPLY
&& this._attr[1] === "integer"
&& (typeof this._attr[2] === "string" || typeof this._attr[2] === "number")
&& Number.isInteger(Number(this._attr[2]));
}
/** @returns {number} integer */
get integer() {
if (!this.isIntegerResponse()) {
return undefined;
}
return Number.parseInt(this._attr[2]);
}
get debugInfo() {
return this.isDebug() ? this._attr[1] : undefined;
}
/** @returns {string|false} Get the username stored in this message */
get username() {
return this.hasUsername() ? this._arr[1] : false;
return this.isUsernameResponse() ? this._attr[2] : false;
}
/** @returns {string|false} Get the password stored in this message */
get password() {
return this.hasPassword() ? this._arr[1] : false;
return this.isPasswordResponse() ? this._attr[2] : false;
}
/** @returns {string} */
get command() {
return this.isCommand() ? this._attr[1] : false;
}
isCommand() {
return this._raw[0] === MSG_COMMAND;
return this.hasCommand() ? this._attr[1] : false;
}
}
/**
* Given a message type and some args, create a string that can be sent from the server to the client (or vise versa)
*
* @param {string} messageType
* @param {...any} args
*/
export function prepare(messageType, ...args) {
return JSON.stringify([messageType, ...args]);
}

View File

@@ -1,42 +0,0 @@
import { randomBytes, pbkdf2Sync, randomInt } from "node:crypto";
// Settings (tune as needed)
const ITERATIONS = 100_000; // Slow enough to deter brute force
const KEYLEN = 64; // 512-bit hash
const DIGEST = "sha512";
/**
* Generate a hash from a plaintext password.
* @param {String} password
* @returns String
*/
export function hash(password) {
const salt = randomBytes(16).toString("hex"); // 128-bit salt
const hash = pbkdf2Sync(
password,
salt,
ITERATIONS,
KEYLEN,
DIGEST,
).toString("hex");
return `${ITERATIONS}:${salt}:${hash}`;
}
/**
* Verify that a password is correct against a given hash.
*
* @param {String} password
* @param {String} hashed_password
* @returns Boolean
*/
export function verify(password, hashed_password) {
const [iterations, salt, hash] = hashed_password.split(":");
const derived = pbkdf2Sync(
password,
salt,
Number(iterations),
KEYLEN,
DIGEST,
).toString("hex");
return hash === derived;
}

59
server/utils/security.js Executable file
View File

@@ -0,0 +1,59 @@
import { randomBytes, pbkdf2Sync } from "node:crypto";
import { DEV } from "./config.js";
// Settings (tune as needed)
const ITERATIONS = 1000;
const KEYLEN = 32; // 32-bit hash
const DIGEST = "sha256";
/**
* Generate a hash from a plaintext password.
* @param {string} password
* @returns {string}
*/
export function generateHash(password) {
const salt = randomBytes(16).toString("hex"); // 128-bit salt
const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString("hex");
return `${ITERATIONS}:${salt}:${hash}`;
}
/**
* Verify that a password is correct against a given hash.
*
* @param {string} password_candidate
* @param {string} stored_password_hash
* @returns {boolean}
*/
export function verifyPassword(password_candidate, stored_password_hash) {
const [iterations, salt, hash] = stored_password_hash.split(":");
const derived = pbkdf2Sync(password_candidate, salt, Number(iterations), KEYLEN, DIGEST).toString("hex");
const success = hash === derived;
if (DEV) {
console.debug(
"Verifying password:\n" +
" Input : %s\n" +
" Stored : %s\n" +
" Given : %s\n" +
" Derived : %s\n" +
" Success : %s",
password_candidate,
generateHash(password_candidate),
stored_password_hash,
derived,
success,
);
}
return success;
}
/** @param {string} candidate */
export function isUsernameSane(candidate) {
return /^[a-zA-Z0-9_]{4,}$/.test(candidate);
}
/** @param {string} candidate */
export function isPasswordSane(candidate) {
// We know the password must adhere to one of our client-side-hashed crypto schemes,
// so we can be fairly strict with the allowed passwords
return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate);
}

306
server/utils/tui.js Executable file
View File

@@ -0,0 +1,306 @@
/**
* @readonly
*
* @enum {string}
*/
export const FrameType = {
/**
* ╔════════════╗
* ║ Hello, TUI ║
* ╚════════════╝
*
* @type {string} Double-lined frame
*/
Double: "Double",
/**
* ┌────────────┐
* │ Hello, TUI │
* └────────────┘
*
* @type {string} Single-lined frame
*/
Single: "Single",
/**
*
* Hello, TUI
*
*
* @type {string} Double-lined frame
*/
Invisible: "Invisible",
/**
* ( )
* ( Hello, TUI )
* ( )
*
* @type {string} Double-lined frame
*/
Parentheses: "Parentheses",
/**
* +------------+
* | Hello, TUI |
* +------------+
*
* @type {string} Double-lined frame
*/
Basic: "Basic",
/**
* @protected
* Default values for the common frame types.
*
* [north, south, east, west, northwest, northeast, southwest, southeast]
*/
values: {
Basic: "--||++++",
Double: "══║║╔╗╚╝",
Invisible: " ",
Parentheses: " () ",
Single: "──││┌┐└┘",
}
}
export class FramingOptions {
/** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */
vPadding = 0;
/** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */
vMargin = 0;
/** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */
hPadding = 0;
/** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */
hMargin = 0;
/** @type {FrameType=FrameType.Double} Type of frame to put around the text */
frameType = FrameType.Double;
/** @type {number=0} Pad each line to become at least this long */
minLineWidth = 0;
// Light block: ░ (U+2591)
// Medium block: ▒ (U+2592)
// Dark block: ▓ (U+2593)
// Solid block: █ (U+2588)
/** @type {string} Single character to use as filler inside the frame. */
paddingChar = " "; // character used for padding inside the frame.
/** @type {string} Single character to use as filler outside the frame. */
marginChar = " ";
/** @type {string} The 8 characters that make up the frame elements */
frameChars = FrameType.values.Double;
/**
* @param {object} o
* @returns {FramingOptions}
*/
static fromObject(o) {
const result = new FramingOptions();
result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0);
result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0);
result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0);
result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0);
result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0);
result.paddingChar = String(o.paddingChar || " ")[0] || " ";
result.marginChar = String(o.marginChar || " ")[0] || " ";
//
// Do we have custom and valid frame chars?
if (typeof o.frameChars === "string" && o.frameChars.length === FrameType.values.Double.length) {
result.frameChars = o.frameChars;
//
// do we have document frame type instead ?
} else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) {
result.frameChars = FrameType.values[o.frameType];
// Fall back to using "Double" frame
} else {
result.frameChars = FrameType.values.Double;
}
return result;
}
}
/**
* @param {string|string[]} text the text to be framed. If array, each element will be treated as one line, and they are joined so the whole is to be framed.
* @param {FramingOptions} options
*/
export function frameText(text, options) {
if (!options) {
options = new FramingOptions();
}
if (!(options instanceof FramingOptions)) {
options = FramingOptions.fromObject(options);
}
// There is a point to this; each element in the array may contain newlines,
// so we have to combine everything into a long text and then split into
// individual lines afterwards.
if (Array.isArray(text)) {
text = text.join("\n");
}
if (typeof text !== "string") {
console.debug(text);
throw new Error(`text argument was neither an array or a string, it was a ${typeof text}`);
}
/** @type {string[]} */
const lines = text.split("\n");
const innerLineLength = Math.max(
lines.reduce((accumulator, currentLine) => {
if (currentLine.length > accumulator) {
return currentLine.length;
}
return accumulator;
}, 0), options.minLineWidth);
const frameThickness = 1; // always 1 for now.
const outerLineLength = 0
+ innerLineLength
+ frameThickness * 2
+ options.hPadding * 2
+ options.hMargin * 2;
// get the frame characters from the frameType.
const [
fNorth, // horizontal frame top lines
fSouth, // horizontal frame bottom lines
fWest, // vertical frame lines on the left side
fEast, // vertical frame lines on the right side
fNorthWest, // upper left frame corner
fNorthEast, // upper right frame corner
fSouthWest, // lower left frame corner
fSouthEast, // lower right frame corner
] = options.frameChars.split("");
let output = "";
//
// GENERATE THE MARGIN SPACE ABOVE THE FRAMED TEXT
//
// ( we insert space characters even though )
// ( they wouldn't normally be visible. But )
// ( Some fonts might allow us to see blank )
// ( space, and what if we want to nest many )
// ( frames inside each other? )
//
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin);
//
// GENERATE THE TOP PART OF THE FRAME
// ╔════════════╗
//
//
output += "" // Make sure JS knows we're adding a string.
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts
+ fNorthWest // northwest frame corner
+ fNorth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar
+ fNorthEast // northeast frame corner
+ options.marginChar.repeat(options.hMargin) // the margin after the frame ends
+ "\n";
//
// GENERATE UPPER PADDING
//
// ║ ║
//
// (the blank lines within the frame and above the text)
output += (
options.marginChar.repeat(options.hMargin)
+ fWest
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2)
+ fEast
+ options.marginChar.repeat(options.hMargin)
+ "\n"
).repeat(options.vPadding);
//
// GENERATE FRAMED TEXT SEGMENT
//
// ║ My pretty ║
// ║ text here ║
//
// ( this could be done with a reduce() )
//
for (const line of lines) {
output += "" // Make sure JS knows we're adding a string.
+ options.marginChar.repeat(options.hMargin) // margin before frame
+ fWest // vertical frame char
+ options.paddingChar.repeat(options.hPadding) // padding before text
+ line.padEnd(innerLineLength, " ") // The actual text. Pad it with normal space character, NOT custom space.
+ options.paddingChar.repeat(options.hPadding) // padding after text
+ fEast // vertical frame bar
+ options.marginChar.repeat(options.hMargin) // margin after frame
+ "\n";
}
//
// GENERATE LOWER PADDING
//
// ║ ║
//
// ( the blank lines within the )
// ( frame and below the text )
//
// ( this code is a direct )
// ( repeat of the code that )
// ( generates top padding )
output += (
options.marginChar.repeat(options.hMargin)
+ fWest
+ options.paddingChar.repeat(innerLineLength + options.hPadding * 2)
+ fEast
+ options.marginChar.repeat(options.hMargin)
+ "\n"
).repeat(options.vPadding);
//
// GENERATE THE BOTTOM PART OF THE FRAME
//
// ╚════════════╝
//
output += "" // Make sure JS knows we're adding a string.
+ options.marginChar.repeat(options.hMargin) // the margin before the frame starts
+ fSouthWest // northwest frame corner
+ fSouth.repeat(innerLineLength + options.hPadding * 2) // the long horizontal frame top bar
+ fSouthEast // northeast frame corner
+ options.marginChar.repeat(options.hMargin) // the margin after the frame starts
+ "\n";
//
// GENERATE THE MARGIN SPACE BELOW THE FRAMED TEXT
//
// ( we insert space characters even though )
// ( they wouldn't normally be visible. But )
// ( Some fonts might allow us to see blank )
// ( space, and what if we want to nest many )
// ( frames inside each other? )
//
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(options.vMargin);
return output;
}
// Allow this script to be run directly from node as well as being included!
// https://stackoverflow.com/a/66309132/5622463