XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESpaceNinjaServer

A simple server for a small space ninja game

公开
关注 0 Fork 0 Star 0
UTF-8
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();
                }
            });
        }
    });
};