import type http from "http";
import type https from "https";
import type { WebSocket } from "ws";
import { WebSocketServer } from "ws";
import { Account } from "../models/loginModel.ts";
import {
createAccount,
createNonce,
getAccountForQuery,
getUsernameFromEmail,
hasPermission,
isAdministrator,
isCorrectPassword
} from "./loginService.ts";
import type { IDatabaseAccountJson } from "../types/loginTypes.ts";
import type { HydratedDocument } from "mongoose";
import { logError, logger } from "../utils/logger.ts";
import type { Request } from "express";
import type { ITunables } from "../types/bootstrapperTypes.ts";
import type { AddressInfo } from "node:net";
import { config } from "./configService.ts";
let wsServer: WebSocketServer | undefined;
let wssServer: WebSocketServer | undefined;
export const startWsServer = (httpServer: http.Server): void => {
wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", wsOnConnect);
};
export const startWssServer = (httpsServer: https.Server): void => {
wssServer = new WebSocketServer({ server: httpsServer });
wssServer.on("connection", wsOnConnect);
};
export const stopWsServers = (promises: Promise<void>[]): void => {
if (wsServer) {
promises.push(
new Promise(resolve => {
wsServer!.close(() => {
resolve();
});
wsServer!.emit("close");
})
);
}
if (wssServer) {
promises.push(
new Promise(resolve => {
wssServer!.close(() => {
resolve();
});
wssServer!.emit("close");
})
);
}
};
let lastWsid: number = 0;
interface IWsCustomData extends WebSocket {
id: number;
address: string;
reflexiveAddress: string;
accountId?: string;
realAccountId?: string;
isGame?: boolean;
guildId?: string;
}
interface IWsMsgFromClient {
auth?: {
email: string;
password: string;
possessing?: string;
isRegister: boolean;
allPermissions: string[];
};
auth_game?:
| {
accountId: string;
nonce: string;
}
| {
accountId: string;
token: string;
};
logout?: boolean;
sync_inventory?: boolean;
allPermissions?: string[];
guildId?: string;
}
interface IWsMsgToClientCommon {
wsid?: number;
ports?: {
http: number | undefined;
https: number | undefined;
};
}
export interface IWsMsgToClientWebui extends IWsMsgToClientCommon {
reload?: boolean;
config_reloaded?: boolean;
auth_succ?: {
id: string;
DisplayName: string;
Nonce: number;
};
permissions?: string[];
auth_fail?: "bad login" | "bad register" | "admin only" | "registered but admin only";
nonce_updated?: boolean;
update_inventory?: boolean;
update_guild?: boolean;
logged_out?: boolean;
have_game_ws?: boolean;
}
// specific to the bootstrapper (https://openwf.io/bootstrapper-manual)
export interface IWsMsgToClientGame extends IWsMsgToClientCommon {
sync_inventory?: boolean;
sync_world_state?: boolean;
tunables?: ITunables;
}
export type IWsMsgToClient = IWsMsgToClientWebui | IWsMsgToClientGame;
const wsOnConnect = (ws: WebSocket, req: http.IncomingMessage): void => {
if (req.url == "/custom/selftest") {
ws.send("SpaceNinjaServer");
ws.close();
return;
}
(ws as IWsCustomData).id = ++lastWsid;
(ws as IWsCustomData).address = (req.socket.address() as AddressInfo).address;
(ws as IWsCustomData).reflexiveAddress = req.headers.host?.split(":")[0] ?? config.myAddress;
ws.send(JSON.stringify({ wsid: lastWsid } satisfies IWsMsgToClient));
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ws.on("message", async msg => {
try {
logger.trace(`incoming websocket message: ${String(msg)}`);
const data = JSON.parse(String(msg)) as IWsMsgFromClient;
if (data.auth) {
let account: IDatabaseAccountJson | null = await Account.findOne({ email: data.auth.email });
let accessedAccount = account;
if (account) {
if (isCorrectPassword(data.auth.password, account.password)) {
if (!account.Nonce) {
account.ClientType = "webui";
account.Nonce = createNonce();
await (account as HydratedDocument<IDatabaseAccountJson>).save();
}
if (data.auth.possessing) {
accessedAccount = isAdministrator(account)
? await Account.findById(data.auth.possessing)
: null;
}
} else {
account = null;
}
} else if (data.auth.isRegister && data.auth.email.indexOf("@") != -1) {
const name = await getUsernameFromEmail(data.auth.email);
account = await createAccount({
email: data.auth.email,
password: data.auth.password,
ClientType: "webui",
BuildLabel: undefined,
LastLogin: new Date(),
DisplayName: name,
Nonce: createNonce()
});
accessedAccount = account;
}
if (account && accessedAccount) {
(ws as IWsCustomData).accountId = accessedAccount.id;
(ws as IWsCustomData).realAccountId = account.id;
if (!config.webui?.adminOnly || isAdministrator(account)) {
ws.send(
JSON.stringify({
auth_succ: {
id: account.id,
DisplayName: accessedAccount.DisplayName,
Nonce: account.Nonce
},
have_game_ws: haveGameWs(accessedAccount.id),
permissions: data.auth.allPermissions.filter(perm =>
hasPermission(accessedAccount, perm)
)
} satisfies IWsMsgToClient)
);
} else {
ws.send(
JSON.stringify({
auth_fail: data.auth.isRegister ? "registered but admin only" : "admin only"
} satisfies IWsMsgToClient)
);
}
} else {
ws.send(
JSON.stringify({
auth_fail: data.auth.isRegister ? "bad register" : "bad login"
} satisfies IWsMsgToClient)
);
}
}
if (data.auth_game) {
(ws as IWsCustomData).isGame = true;
try {
const account = await getAccountForQuery({ ...data.auth_game, ct: "WS" }, "WS");
const accountId = account._id.toString();
(ws as IWsCustomData).accountId = accountId;
(ws as IWsCustomData).realAccountId = accountId;
logger.debug(`got bootstrapper connection for ${accountId}`);
sendWsBroadcastToWebui({ have_game_ws: true }, accountId);
} catch (e) {
/* empty */
}
}
if (data.logout) {
const accountId = (ws as IWsCustomData).realAccountId;
if (accountId && !(ws as IWsCustomData).isGame) {
(ws as IWsCustomData).accountId = undefined;
(ws as IWsCustomData).realAccountId = undefined;
const stat = await Account.updateOne(
{
_id: accountId,
ClientType: "webui"
},
{
Nonce: 0
}
);
if (stat.modifiedCount) {
handleNonceInvalidation(accountId);
}
}
}
if (data.sync_inventory) {
const accountId = (ws as IWsCustomData).accountId;
if (accountId && !(ws as IWsCustomData).isGame) {
sendWsBroadcastToGame(accountId, { sync_inventory: true });
}
}
if (data.allPermissions) {
const accountId = (ws as IWsCustomData).accountId;
if (accountId && !(ws as IWsCustomData).isGame) {
const account = (await Account.findById(accountId))!;
ws.send(
JSON.stringify({
permissions: data.allPermissions.filter(perm => hasPermission(account, perm))
} satisfies IWsMsgToClient)
);
}
}
if (data.guildId) {
(ws as IWsCustomData).guildId = data.guildId;
}
} catch (e) {
logError(e as Error, `processing websocket message`);
}
});
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ws.on("close", async () => {
if ((ws as IWsCustomData).isGame && (ws as IWsCustomData).accountId) {
logger.debug(`lost bootstrapper connection for ${(ws as IWsCustomData).accountId}`);
sendWsBroadcastToWebui({ have_game_ws: false }, (ws as IWsCustomData).accountId);
await Account.updateOne(
{
_id: (ws as IWsCustomData).accountId
},
{
Dropped: true
}
);
}
});
};
export const forEachWsClient = (cb: (client: IWsCustomData) => void): void => {
if (wsServer) {
for (const client of wsServer.clients) {
cb(client as IWsCustomData);
}
}
if (wssServer) {
for (const client of wssServer.clients) {
cb(client as IWsCustomData);
}
}
};
const haveGameWs = (accountId: string): boolean => {
let ret = false;
forEachWsClient(client => {
if (client.isGame && client.accountId == accountId) {
ret = true;
}
});
return ret;
};
export const sendWsBroadcast = (data: IWsMsgToClient): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
client.send(msg);
});
};
export const sendWsBroadcastTo = (accountId: string, data: IWsMsgToClient): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (client.accountId == accountId) {
client.send(msg);
}
});
};
export const sendWsBroadcastToGame = (accountId: string | undefined, data: IWsMsgToClientGame): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (client.isGame && (!accountId || client.accountId == accountId)) {
client.send(msg);
}
});
};
export const sendWsBroadcastEx = (data: IWsMsgToClient, accountId?: string, excludeWsid?: number): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if ((!accountId || client.accountId == accountId) && client.id != excludeWsid) {
client.send(msg);
}
});
};
export const sendWsBroadcastToWebui = (data: IWsMsgToClientWebui, accountId?: string, excludeWsid?: number): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (!client.isGame && (!accountId || client.accountId == accountId) && client.id != excludeWsid) {
client.send(msg);
}
});
};
export const broadcastInventoryUpdate = (req: Request): void => {
const accountId = req.query.accountId as string;
if (req.query.wsid) {
// for webui requests, let other tabs and the game know
sendWsBroadcastEx(
{ sync_inventory: true, update_inventory: true },
accountId,
parseInt(String(req.query.wsid))
);
} else {
// for game requests, let all webui tabs know
sendWsBroadcastToWebui({ update_inventory: true }, accountId, parseInt(String(req.query.wsid)));
}
};
export const broadcastGuildUpdate = (req: Request, guildId: string): void => {
// only webui tabs will receive this event. if the request already came from the webui, the requesting tab will not get this event.
const msg = JSON.stringify({ update_guild: true } satisfies IWsMsgToClientWebui);
const wsid = parseInt(String(req.query.wsid));
forEachWsClient(client => {
if (!client.isGame && client.guildId == guildId && client.id != wsid) {
client.send(msg);
}
});
};
export const handleNonceInvalidation = (accountId: string): void => {
forEachWsClient(client => {
if (client.realAccountId == accountId) {
if (client.isGame) {
client.accountId = undefined; // prevent processing of the close event
client.close();
} else {
client.send(JSON.stringify({ nonce_updated: true, have_game_ws: false } satisfies IWsMsgToClientWebui));
}
}
});
};
export const bootNonAdminsFromWebui = (): void => {
forEachWsClient(client => {
if (client.accountId && !client.isGame) {
void Account.findById(client.realAccountId).then(account => {
if (!account || !isAdministrator(account)) {
client.send(JSON.stringify({ logged_out: true } satisfies IWsMsgToClientWebui));
client.close();
}
});
}
});
};
import type http from "http";
import type https from "https";
import type { WebSocket } from "ws";
import { WebSocketServer } from "ws";
import { Account } from "../models/loginModel.ts";
import {
createAccount,
createNonce,
getAccountForQuery,
getUsernameFromEmail,
hasPermission,
isAdministrator,
isCorrectPassword
} from "./loginService.ts";
import type { IDatabaseAccountJson } from "../types/loginTypes.ts";
import type { HydratedDocument } from "mongoose";
import { logError, logger } from "../utils/logger.ts";
import type { Request } from "express";
import type { ITunables } from "../types/bootstrapperTypes.ts";
import type { AddressInfo } from "node:net";
import { config } from "./configService.ts";
let wsServer: WebSocketServer | undefined;
let wssServer: WebSocketServer | undefined;
export const startWsServer = (httpServer: http.Server): void => {
wsServer = new WebSocketServer({ server: httpServer });
wsServer.on("connection", wsOnConnect);
};
export const startWssServer = (httpsServer: https.Server): void => {
wssServer = new WebSocketServer({ server: httpsServer });
wssServer.on("connection", wsOnConnect);
};
export const stopWsServers = (promises: Promise<void>[]): void => {
if (wsServer) {
promises.push(
new Promise(resolve => {
wsServer!.close(() => {
resolve();
});
wsServer!.emit("close");
})
);
}
if (wssServer) {
promises.push(
new Promise(resolve => {
wssServer!.close(() => {
resolve();
});
wssServer!.emit("close");
})
);
}
};
let lastWsid: number = 0;
interface IWsCustomData extends WebSocket {
id: number;
address: string;
reflexiveAddress: string;
accountId?: string;
realAccountId?: string;
isGame?: boolean;
guildId?: string;
}
interface IWsMsgFromClient {
auth?: {
email: string;
password: string;
possessing?: string;
isRegister: boolean;
allPermissions: string[];
};
auth_game?:
| {
accountId: string;
nonce: string;
}
| {
accountId: string;
token: string;
};
logout?: boolean;
sync_inventory?: boolean;
allPermissions?: string[];
guildId?: string;
}
interface IWsMsgToClientCommon {
wsid?: number;
ports?: {
http: number | undefined;
https: number | undefined;
};
}
export interface IWsMsgToClientWebui extends IWsMsgToClientCommon {
reload?: boolean;
config_reloaded?: boolean;
auth_succ?: {
id: string;
DisplayName: string;
Nonce: number;
};
permissions?: string[];
auth_fail?: "bad login" | "bad register" | "admin only" | "registered but admin only";
nonce_updated?: boolean;
update_inventory?: boolean;
update_guild?: boolean;
logged_out?: boolean;
have_game_ws?: boolean;
}
// specific to the bootstrapper (https://openwf.io/bootstrapper-manual)
export interface IWsMsgToClientGame extends IWsMsgToClientCommon {
sync_inventory?: boolean;
sync_world_state?: boolean;
tunables?: ITunables;
}
export type IWsMsgToClient = IWsMsgToClientWebui | IWsMsgToClientGame;
const wsOnConnect = (ws: WebSocket, req: http.IncomingMessage): void => {
if (req.url == "/custom/selftest") {
ws.send("SpaceNinjaServer");
ws.close();
return;
}
(ws as IWsCustomData).id = ++lastWsid;
(ws as IWsCustomData).address = (req.socket.address() as AddressInfo).address;
(ws as IWsCustomData).reflexiveAddress = req.headers.host?.split(":")[0] ?? config.myAddress;
ws.send(JSON.stringify({ wsid: lastWsid } satisfies IWsMsgToClient));
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ws.on("message", async msg => {
try {
logger.trace(`incoming websocket message: ${String(msg)}`);
const data = JSON.parse(String(msg)) as IWsMsgFromClient;
if (data.auth) {
let account: IDatabaseAccountJson | null = await Account.findOne({ email: data.auth.email });
let accessedAccount = account;
if (account) {
if (isCorrectPassword(data.auth.password, account.password)) {
if (!account.Nonce) {
account.ClientType = "webui";
account.Nonce = createNonce();
await (account as HydratedDocument<IDatabaseAccountJson>).save();
}
if (data.auth.possessing) {
accessedAccount = isAdministrator(account)
? await Account.findById(data.auth.possessing)
: null;
}
} else {
account = null;
}
} else if (data.auth.isRegister && data.auth.email.indexOf("@") != -1) {
const name = await getUsernameFromEmail(data.auth.email);
account = await createAccount({
email: data.auth.email,
password: data.auth.password,
ClientType: "webui",
BuildLabel: undefined,
LastLogin: new Date(),
DisplayName: name,
Nonce: createNonce()
});
accessedAccount = account;
}
if (account && accessedAccount) {
(ws as IWsCustomData).accountId = accessedAccount.id;
(ws as IWsCustomData).realAccountId = account.id;
if (!config.webui?.adminOnly || isAdministrator(account)) {
ws.send(
JSON.stringify({
auth_succ: {
id: account.id,
DisplayName: accessedAccount.DisplayName,
Nonce: account.Nonce
},
have_game_ws: haveGameWs(accessedAccount.id),
permissions: data.auth.allPermissions.filter(perm =>
hasPermission(accessedAccount, perm)
)
} satisfies IWsMsgToClient)
);
} else {
ws.send(
JSON.stringify({
auth_fail: data.auth.isRegister ? "registered but admin only" : "admin only"
} satisfies IWsMsgToClient)
);
}
} else {
ws.send(
JSON.stringify({
auth_fail: data.auth.isRegister ? "bad register" : "bad login"
} satisfies IWsMsgToClient)
);
}
}
if (data.auth_game) {
(ws as IWsCustomData).isGame = true;
try {
const account = await getAccountForQuery({ ...data.auth_game, ct: "WS" }, "WS");
const accountId = account._id.toString();
(ws as IWsCustomData).accountId = accountId;
(ws as IWsCustomData).realAccountId = accountId;
logger.debug(`got bootstrapper connection for ${accountId}`);
sendWsBroadcastToWebui({ have_game_ws: true }, accountId);
} catch (e) {
/* empty */
}
}
if (data.logout) {
const accountId = (ws as IWsCustomData).realAccountId;
if (accountId && !(ws as IWsCustomData).isGame) {
(ws as IWsCustomData).accountId = undefined;
(ws as IWsCustomData).realAccountId = undefined;
const stat = await Account.updateOne(
{
_id: accountId,
ClientType: "webui"
},
{
Nonce: 0
}
);
if (stat.modifiedCount) {
handleNonceInvalidation(accountId);
}
}
}
if (data.sync_inventory) {
const accountId = (ws as IWsCustomData).accountId;
if (accountId && !(ws as IWsCustomData).isGame) {
sendWsBroadcastToGame(accountId, { sync_inventory: true });
}
}
if (data.allPermissions) {
const accountId = (ws as IWsCustomData).accountId;
if (accountId && !(ws as IWsCustomData).isGame) {
const account = (await Account.findById(accountId))!;
ws.send(
JSON.stringify({
permissions: data.allPermissions.filter(perm => hasPermission(account, perm))
} satisfies IWsMsgToClient)
);
}
}
if (data.guildId) {
(ws as IWsCustomData).guildId = data.guildId;
}
} catch (e) {
logError(e as Error, `processing websocket message`);
}
});
// eslint-disable-next-line @typescript-eslint/no-misused-promises
ws.on("close", async () => {
if ((ws as IWsCustomData).isGame && (ws as IWsCustomData).accountId) {
logger.debug(`lost bootstrapper connection for ${(ws as IWsCustomData).accountId}`);
sendWsBroadcastToWebui({ have_game_ws: false }, (ws as IWsCustomData).accountId);
await Account.updateOne(
{
_id: (ws as IWsCustomData).accountId
},
{
Dropped: true
}
);
}
});
};
export const forEachWsClient = (cb: (client: IWsCustomData) => void): void => {
if (wsServer) {
for (const client of wsServer.clients) {
cb(client as IWsCustomData);
}
}
if (wssServer) {
for (const client of wssServer.clients) {
cb(client as IWsCustomData);
}
}
};
const haveGameWs = (accountId: string): boolean => {
let ret = false;
forEachWsClient(client => {
if (client.isGame && client.accountId == accountId) {
ret = true;
}
});
return ret;
};
export const sendWsBroadcast = (data: IWsMsgToClient): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
client.send(msg);
});
};
export const sendWsBroadcastTo = (accountId: string, data: IWsMsgToClient): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (client.accountId == accountId) {
client.send(msg);
}
});
};
export const sendWsBroadcastToGame = (accountId: string | undefined, data: IWsMsgToClientGame): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (client.isGame && (!accountId || client.accountId == accountId)) {
client.send(msg);
}
});
};
export const sendWsBroadcastEx = (data: IWsMsgToClient, accountId?: string, excludeWsid?: number): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if ((!accountId || client.accountId == accountId) && client.id != excludeWsid) {
client.send(msg);
}
});
};
export const sendWsBroadcastToWebui = (data: IWsMsgToClientWebui, accountId?: string, excludeWsid?: number): void => {
const msg = JSON.stringify(data);
forEachWsClient(client => {
if (!client.isGame && (!accountId || client.accountId == accountId) && client.id != excludeWsid) {
client.send(msg);
}
});
};
export const broadcastInventoryUpdate = (req: Request): void => {
const accountId = req.query.accountId as string;
if (req.query.wsid) {
// for webui requests, let other tabs and the game know
sendWsBroadcastEx(
{ sync_inventory: true, update_inventory: true },
accountId,
parseInt(String(req.query.wsid))
);
} else {
// for game requests, let all webui tabs know
sendWsBroadcastToWebui({ update_inventory: true }, accountId, parseInt(String(req.query.wsid)));
}
};
export const broadcastGuildUpdate = (req: Request, guildId: string): void => {
// only webui tabs will receive this event. if the request already came from the webui, the requesting tab will not get this event.
const msg = JSON.stringify({ update_guild: true } satisfies IWsMsgToClientWebui);
const wsid = parseInt(String(req.query.wsid));
forEachWsClient(client => {
if (!client.isGame && client.guildId == guildId && client.id != wsid) {
client.send(msg);
}
});
};
export const handleNonceInvalidation = (accountId: string): void => {
forEachWsClient(client => {
if (client.realAccountId == accountId) {
if (client.isGame) {
client.accountId = undefined; // prevent processing of the close event
client.close();
} else {
client.send(JSON.stringify({ nonce_updated: true, have_game_ws: false } satisfies IWsMsgToClientWebui));
}
}
});
};
export const bootNonAdminsFromWebui = (): void => {
forEachWsClient(client => {
if (client.accountId && !client.isGame) {
void Account.findById(client.realAccountId).then(account => {
if (!account || !isAdministrator(account)) {
client.send(JSON.stringify({ logged_out: true } satisfies IWsMsgToClientWebui));
client.close();
}
});
}
});
};