From 6aae9129b53872e47a6c9a7bb79684c81ea709e5 Mon Sep 17 00:00:00 2001 From: zombieb Date: Sat, 29 Mar 2025 01:59:28 -0400 Subject: [PATCH] That's a spicy meatball * APIUtils additions * Socket and web server listen on dedicated ports (see denoland/deno socket issue created by ZombieB1309 on GitHub) * Coach and Server created automatically (untested) * Profile content functions split into 'managers' * Progression temporary implementation * Settings placed into profile content manager * Relationships and messages return temporary empty array * Socket targets defined, message delivery to target, exec returned (goes unused for now) --- deno.json | 1 + deno.lock | 8 ++ src/apiutils.ts | 29 ++++- src/config.ts | 23 +++- src/data/config.ts | 3 + src/data/live/presence.ts | 2 +- src/data/profile/profilemanagerbase.ts | 15 +++ src/data/profile/progression.ts | 35 ++++++ src/data/profile/settings.ts | 40 +++++++ src/data/profiles.ts | 47 ++++---- src/data/profiletypes.ts | 4 - src/data/users.ts | 6 +- src/main.ts | 121 +++++++++++--------- src/routes/api.ts | 6 +- src/routes/api/config.ts | 7 ++ src/routes/api/messages.ts | 13 +++ src/routes/api/players.ts | 17 +++ src/routes/api/relationships.ts | 15 +++ src/routes/auth/cachedlogin.ts | 2 + src/routes/auth/connect.ts | 5 + src/routes/match/player.ts | 2 + src/routes/nameserver.ts | 24 ++-- src/socket/route.ts | 12 +- src/socket/socket.ts | 66 ++++++++--- src/socket/targets/SubscribeToPlayers.ts | 16 +++ src/socket/targets/targetbase.ts | 20 ++++ src/socket/types.ts | 137 ++++++++++++++++++++--- src/types/express.ts | 1 + 28 files changed, 529 insertions(+), 148 deletions(-) create mode 100644 src/data/profile/profilemanagerbase.ts create mode 100644 src/data/profile/progression.ts create mode 100644 src/data/profile/settings.ts delete mode 100644 src/data/profiletypes.ts create mode 100644 src/routes/api/messages.ts create mode 100644 src/routes/api/players.ts create mode 100644 src/routes/api/relationships.ts create mode 100644 src/socket/targets/SubscribeToPlayers.ts create mode 100644 src/socket/targets/targetbase.ts diff --git a/deno.json b/deno.json index 657dc27..4aabc82 100644 --- a/deno.json +++ b/deno.json @@ -12,6 +12,7 @@ "@std/http": "jsr:@std/http@^1.0.13", "@types/cookie-parser": "npm:@types/cookie-parser@^1.4.8", "@types/express": "npm:@types/express@^5.0.0", + "@types/node": "npm:@types/node@^22.13.14", "@types/validator": "npm:@types/validator@^13.12.2", "@types/ws": "npm:@types/ws@^8.18.0", "cookie-parser": "npm:cookie-parser@^1.4.7", diff --git a/deno.lock b/deno.lock index 8e9a619..1017d79 100644 --- a/deno.lock +++ b/deno.lock @@ -23,6 +23,7 @@ "npm:@types/express@*": "5.0.0", "npm:@types/express@5": "5.0.0", "npm:@types/node@*": "22.5.4", + "npm:@types/node@^22.13.14": "22.13.14", "npm:@types/validator@^13.12.2": "13.12.2", "npm:@types/ws@^8.18.0": "8.18.0", "npm:chalk@^5.3.0": "5.3.0", @@ -228,6 +229,12 @@ "undici-types@6.20.0" ] }, + "@types/node@22.13.14": { + "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==", + "dependencies": [ + "undici-types@6.20.0" + ] + }, "@types/node@22.5.4": { "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", "dependencies": [ @@ -931,6 +938,7 @@ "jsr:@std/http@^1.0.13", "npm:@types/cookie-parser@^1.4.8", "npm:@types/express@5", + "npm:@types/node@^22.13.14", "npm:@types/validator@^13.12.2", "npm:@types/ws@^8.18.0", "npm:cookie-parser@^1.4.7", diff --git a/src/apiutils.ts b/src/apiutils.ts index 1906564..c2fcd39 100644 --- a/src/apiutils.ts +++ b/src/apiutils.ts @@ -6,7 +6,6 @@ import { Config } from "./config.ts"; import { AuthType, User, UserTokenFormat } from "./data/users.ts"; import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts"; import z from "zod"; -import { IncomingMessage } from "node:http"; const config = Config.getConfig(); @@ -25,6 +24,11 @@ export function createRouter(path: string) { return router; } +export function setCacheAllowed(_rq: express.Request, rs: express.Response, nxt: express.NextFunction) { + rs.setHeader("Cache-Control", 'public'); + nxt(); +} + export function generateRandomString(length: number) { const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -132,14 +136,14 @@ export function getSrcIpDefault(rq: express.Request): string { return typeof rq.ip === "undefined" ? "(unknown source)" : rq.ip; } -export function getSrcIpDefaultRaw(rq: IncomingMessage) { - const cfIp = rq.headers['cf-connecting-ip']; +export function getSrcIpDefaultDeno(req: Request, info: Deno.ServeHandlerInfo) { + const cfIp = req.headers.get('cf-connecting-ip'); if (cfIp) return cfIp; - const xrIp = rq.headers['x-real-ip']; + const xrIp = req.headers.get('x-real-ip'); if (xrIp) return xrIp; - return rq.socket.remoteAddress ? rq.socket.remoteAddress : "(unknown source)"; + return info.remoteAddr.hostname; } export function statusResponse(code: number) { @@ -277,11 +281,12 @@ export async function Authentication( } const valid = ![ // used to contain more conditions, now is only 1 - decodedToken.iss == `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`, + decodedToken.iss == `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`, ].includes(false); if (valid) { if (decodedToken.typ == AuthType.Web) rs.locals.user = new User(decodedToken.sub); else if (decodedToken.typ == AuthType.Game) rs.locals.profile = UnifiedProfile.get(decodedToken.sub); + rs.locals.token = token; nxt(); } else { @@ -294,6 +299,18 @@ export async function Authentication( } } +export function AuthenticationType(type: AuthType) { + return (_rq: express.Request, rs: express.Response, nxt: express.NextFunction) => { + const profile = rs.locals.profile; + const user = rs.locals.user; + + if ((type == AuthType.Game && !profile) || (type == AuthType.Web && !user)) { + rs.json(genericResponseFormat(true, 'Wrong authentication type provided.')); + return; + } else nxt(); + } +} + export type NoBody = Record; export * as APIUtils from "./apiutils.ts"; diff --git a/src/config.ts b/src/config.ts index f2b3c6e..167899f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,11 @@ type WebConfiguration = { host: string; publichost: string; securepublichost: boolean; +} + +type WebRootConfiguration = { + api: WebConfiguration, + socket: WebConfiguration }; type PublicConfiguration = { @@ -52,7 +57,7 @@ type AuthConfiguration = { export type GalvanicConfiguration = { redis: RedisConfiguration; - web: WebConfiguration; + web: WebRootConfiguration; public: PublicConfiguration; logging: LoggingConfiguration; discord: DiscordConfiguration | null; @@ -68,10 +73,18 @@ export const defaultConfig: GalvanicConfiguration = { db: 0, }, web: { - port: 3000, - host: "127.0.0.1", - publichost: "127.0.0.1:3000", - securepublichost: false, + api: { + port: 13370, + host: "127.0.0.1", + publichost: "127.0.0.1:13370", + securepublichost: false, + }, + socket: { + port: 13371, + host: "127.0.0.1", + publichost: "127.0.0.1:13371", + securepublichost: false, + } }, public: { serverName: "Galvanic Corrosion", diff --git a/src/data/config.ts b/src/data/config.ts index 3889dee..e2598aa 100644 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -19,6 +19,9 @@ export type PublicConfig = { ConfigTable: Config[]; }; +/** + * Plain public config, NOT GameConfigs + */ export function getConfig() { const c = Config.getConfig(); if (typeof c == "undefined") return null; diff --git a/src/data/live/presence.ts b/src/data/live/presence.ts index b19f826..4b3be30 100644 --- a/src/data/live/presence.ts +++ b/src/data/live/presence.ts @@ -55,7 +55,7 @@ class PlayerPresence { const PlayerStatusVisibilityEnum = z.nativeEnum(PlayerStatusVisibility); type PlayerStatusVisibilityEnum = z.infer; - const visibilityResult = PlayerStatusVisibilityEnum.safeParse(await this.#profile.getSetting(SettingKey.PlayerStatusVisibility)); + const visibilityResult = PlayerStatusVisibilityEnum.safeParse(await this.#profile.Settings.getSetting(SettingKey.PlayerStatusVisibility)); if (visibilityResult.success) this.statusVisibility = visibilityResult.data; } diff --git a/src/data/profile/profilemanagerbase.ts b/src/data/profile/profilemanagerbase.ts new file mode 100644 index 0000000..c8335b7 --- /dev/null +++ b/src/data/profile/profilemanagerbase.ts @@ -0,0 +1,15 @@ +export class ProfileContentManager { + + profileNotSetError = new Error("The profile on this manager is not set."); + + profileId: number | null = null; + + setProfile(id: number) { + this.profileId = id; + } + + profileIsSet() { + return this.profileId !== null; + } + +} \ No newline at end of file diff --git a/src/data/profile/progression.ts b/src/data/profile/progression.ts new file mode 100644 index 0000000..8612f4f --- /dev/null +++ b/src/data/profile/progression.ts @@ -0,0 +1,35 @@ +import { Config } from "../../config.ts"; +import { GameConfigs } from "../config.ts"; +import { ProfileContentManager } from "./profilemanagerbase.ts"; + +const serverConfig = Config.getConfig(); +const config = GameConfigs.getConfig(); +/** + * Level -> Required XP + */ +const requiredXpMap: Map = new Map(); + +export class ProfileProgressionManager extends ProfileContentManager { + + constructor() { + super(); + // fill `requiredXpMap` using `config.public` values + } + + #getRequiredXp(level: number) { + if (level > serverConfig.public.maxLevels) return null; + else { + const req = requiredXpMap.get(level); + return req ? req : null; + } + } + + getLevel() { + return 30; // temporary + } + + getXp() { + return 0; // temporary + } + +} \ No newline at end of file diff --git a/src/data/profile/settings.ts b/src/data/profile/settings.ts new file mode 100644 index 0000000..27004e1 --- /dev/null +++ b/src/data/profile/settings.ts @@ -0,0 +1,40 @@ +import { Redis } from "../../db.ts"; +import { SettingKey } from "../content/settings.ts"; +import { ProfileContentManager } from "./profilemanagerbase.ts"; + +export interface Setting { + Key: string; + Value: string; +} + +export class ProfileSettingsManager extends ProfileContentManager { + + async getSettings() { + if (!this.profileIsSet()) throw this.profileNotSetError; + const settings = await Redis.Database.hgetall(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId!.toString(), Redis.KeyGroups.Profiles.Settings)); + const returnSettings: Setting[] = []; + for (const key of Object.keys(settings)) returnSettings.push({ Key: key, Value: settings[key] }); + return returnSettings; + } + + async getSetting(key: SettingKey) { + if (!this.profileIsSet()) throw this.profileNotSetError; + return await Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId!.toString(), Redis.KeyGroups.Profiles.Settings), key); + } + + async setSetting(key: SettingKey, value: string) { + if (!this.profileIsSet()) throw this.profileNotSetError; + await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId!.toString(), Redis.KeyGroups.Profiles.Settings), key, value); + } + + async delSetting(key: SettingKey) { + if (!this.profileIsSet()) throw this.profileNotSetError; + await Redis.Database.hdel(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId!.toString(), Redis.KeyGroups.Profiles.Settings), key); + } + + async delAllSettings() { + if (!this.profileIsSet()) throw this.profileNotSetError; + await Redis.Database.del(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.profileId!.toString(), Redis.KeyGroups.Profiles.Settings)); + } + +} \ No newline at end of file diff --git a/src/data/profiles.ts b/src/data/profiles.ts index 76ab6aa..19d8d4b 100644 --- a/src/data/profiles.ts +++ b/src/data/profiles.ts @@ -5,10 +5,11 @@ import { AuthType } from "./users.ts"; import * as JsonWebToken from "@gz/jwt"; import { TokenBaseFormat } from "../apiutils.ts"; import { DeviceClass, RoomInstance, VRMovementMode } from "./live/types.ts"; -import { Setting } from "./profiletypes.ts"; import { SettingKey } from "./content/settings.ts"; import { z } from "zod"; import { SignalRSocketHandler } from "../socket/socket.ts"; +import { ProfileSettingsManager } from "./profile/settings.ts"; +import { ProfileProgressionManager } from "./profile/progression.ts"; const config = Config.getConfig(); @@ -140,8 +141,15 @@ class Profile { #socket: SignalRSocketHandler | null = null; + Settings = new ProfileSettingsManager(); + Progression = new ProfileProgressionManager(); + constructor(id: number) { this.#id = id; + + // Set IDs for all content managers + this.Settings.setProfile(this.#id); + this.Progression.setProfile(this.#id); } setInstance(instance: RoomInstance | null) { @@ -164,29 +172,6 @@ class Profile { return await Profile.getExportAccount(this.#id); } - async getSettings() { - const settings = await Redis.Database.hgetall(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Settings)); - const returnSettings: Setting[] = []; - for (const key of Object.keys(settings)) returnSettings.push({Key: key, Value: settings[key]}); - return returnSettings; - } - - async getSetting(key: SettingKey) { - return await Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Settings), key); - } - - async setSetting(key: SettingKey, value: string) { - await Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Settings), key, value); - } - - async delSetting(key: SettingKey) { - await Redis.Database.hdel(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Settings), key); - } - - async delAllSettings() { - await Redis.Database.del(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Settings)); - } - async setKnownDeviceClass(deviceClass: string | number) { await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass), deviceClass); } @@ -203,11 +188,11 @@ class Profile { } async setVRMovementMode(movementMode: string | number) { - return await this.setSetting(SettingKey.VRMovementMode, movementMode.toString()); + return await this.Settings.setSetting(SettingKey.VRMovementMode, movementMode.toString()); } async getVRMovementMode() { - const data = await this.getSetting(SettingKey.VRMovementMode); + const data = await this.Settings.getSetting(SettingKey.VRMovementMode); const VRMovementModeEnum = z.nativeEnum(VRMovementMode); type VRMovementModeEnum = z.infer @@ -232,7 +217,7 @@ class Profile { async getToken() { const payload: ProfileTokenFormat = { - iss: `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`, + iss: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`, sub: this.#id, role: (await this.getIsOperator()) ? 'developer' : 'user', exp: Math.round(Date.now() / 1000) + Math.round(config.auth.timeout * 60 * 60), @@ -256,6 +241,14 @@ class UnifiedProfileBase { return profile; } + async create(options: ProfileInitOptions) { + return await Profile.init(options); + } + + async exists(id: number) { + return await Profile.exists(id); + } + } const UnifiedProfile = new UnifiedProfileBase(); diff --git a/src/data/profiletypes.ts b/src/data/profiletypes.ts deleted file mode 100644 index 9475045..0000000 --- a/src/data/profiletypes.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Setting { - Key: string; - Value: string; -} \ No newline at end of file diff --git a/src/data/users.ts b/src/data/users.ts index 77d8b12..2b91d76 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -9,10 +9,6 @@ type UserInitOptions = { pubkey: string; }; -type UserCreatedObj = { - user: User; -}; - export enum AuthType { Game, Web, @@ -78,7 +74,7 @@ export class User { async getToken() { const payload: UserTokenFormat = { - iss: `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`, + iss: `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`, sub: this.#client_id, exp: Math.round(Date.now() / 1000) + (config.auth.timeout * 60 * 60), typ: AuthType.Web diff --git a/src/main.ts b/src/main.ts index 07ddf2f..cc70f34 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,12 +6,10 @@ import { Discord } from "./discord.ts"; import { generateRandomString } from "./apiutils.ts"; // @ts-types = "npm:@types/express" import express from "express"; -import WebSocket, { WebSocketServer } from "ws"; import { decode } from "@gz/jwt"; import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts"; import { SocketHandoff } from "./socket/handoff.ts"; import { SignalRSocketHandler } from "./socket/socket.ts"; -import { IncomingMessage } from "node:http"; const instanceId = generateRandomString(64); @@ -44,8 +42,8 @@ try { Deno.exit(1); } -const port = config.web.port; -const host = config.web.host; +const port = config.web.api.port; +const host = config.web.api.host; log.n(`Starting HTTP server on http://${host}:${port}`); @@ -62,7 +60,7 @@ app.use( }, ); -app.get("/info", (_rq, rs) => { +app.get("/info", APIUtils.setCacheAllowed, (_rq, rs) => { rs.json({ name: config.public.serverName, id: config.public.serverId, @@ -102,12 +100,19 @@ try { * Galvanic WebSocket Server */ - type AuthResult = { - token?: ProfileTokenFormat, + type AuthResultBase = { valid: boolean } - const authenticate = async (req: IncomingMessage) => { - const authHeader = req.headers.authorization; + interface SuccessfulAuth extends AuthResultBase { + token: ProfileTokenFormat, + valid: true + } + interface FailedAuth extends AuthResultBase { + valid: false + } + type AuthResult = FailedAuth | SuccessfulAuth; + const authenticate = async (req: Request) => { + const authHeader = req.headers.get('authorization'); if (!authHeader) return { valid: false } as AuthResult; const token = authHeader.split(" ")[1]; @@ -118,29 +123,52 @@ try { if (!schemaResult.success) return { valid: false } as AuthResult; else return { token: decodedToken, valid: true } as AuthResult; } + + const abort = new AbortController(); - const wss = new WebSocketServer({ noServer: true, path: "/notify/hub/v1" }); - wss.on('connection', (socket: WebSocket, req: IncomingMessage) => { - if (!req.token) { - socket.close(); - return; + // Galvanic WebSocket + Deno.serve({port: config.web.socket.port, hostname: config.web.socket.host, signal: abort.signal, onListen: addr => { + log.n(`Socket listening on http://${addr.hostname}:${addr.port}`); + }}, async (req: Request, info: Deno.ServeHandlerInfo) => { + const path = new URL(req.url).pathname; + const upgrade = req.headers.get('Upgrade') === 'websocket'; + log.n(`U:${upgrade}; ${info.remoteAddr.hostname}:${info.remoteAddr.port} ${req.method} ${path}`); + + if (path === '/negotiate' && req.method == 'POST') + return new Response(JSON.stringify({})); + + + if (!upgrade) return new Response(null, { status: 401 }); + + const authResult = await authenticate(req); + + if (authResult.valid) { + + // ID is given as "/notify/hub/v1?&id=pprhdSzJn" by the client. + let handoff: SocketHandoff | undefined; + if (req.url) { + const pathParts = req.url.replace('v1', '').split('/'); + const query = new URLSearchParams(pathParts[pathParts.length - 1]); + const connectionId = query.get('id'); + if (connectionId) handoff = SocketHandoff.find(connectionId); + } + + if (handoff) handoff.complete(); + + const { socket, response } = Deno.upgradeWebSocket(req); + new SignalRSocketHandler(socket, UnifiedProfile.get(authResult.token.sub)); + + return response; + + } else { + log.e(`401 ${info.remoteAddr} ${req.method} ${req.url}`); + return new Response(null, { status: 401 }); } - // ID is given as "/notify/hub/v1?&id=pprhdSzJn" by the client. - let handoff: SocketHandoff | undefined; - if (req.url) { - const pathParts = req.url.replace('v1', '').split('/'); - const query = new URLSearchParams(pathParts[pathParts.length - 1]); - const connectionId = query.get('id'); - if (connectionId) handoff = SocketHandoff.find(connectionId); - } - - if (handoff) handoff.complete(); - new SignalRSocketHandler(socket, UnifiedProfile.get(req.token.sub)); }); - const http = app.listen(config.web.port, config.web.host, () => { - log.n(`Listening on http://${config.web.host}:${config.web.port}`); + const http = app.listen(config.web.api.port, config.web.api.host, async () => { + log.n(`Web listening on http://${config.web.api.host}:${config.web.api.port}`); let shuttingDown = false; Deno.addSignalListener("SIGINT", () => { @@ -148,40 +176,27 @@ try { shuttingDown = true; log.i(`Shutting down`); + abort.abort(); // websockets http.close(); http.closeAllConnections(); }); Deno.addSignalListener("SIGINT", () => { for (const handoff of SocketHandoff.all()) handoff.complete(); }); + + + /* + PLACE TEST HERE + */ + + if (!(await UnifiedProfile.exists(1))) UnifiedProfile.create({ username: "Coach" }); // create Coach if they do not exist + if (!(await UnifiedProfile.exists(2))) UnifiedProfile.create({ username: "Server" }); // create Server if they do not exist + }); - // Currently not working in Deno. Socket problem? - /*http.on('upgrade', async (req, socket, head) => { - log.d('Handling upgrade'); - try { - const authResult = await authenticate(req); - - if (authResult.valid) { - req.token = authResult.token; - - log.d('Auth result was valid.'); - wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit('connection', ws, req); - }); - } else { - // Reject the upgrade - log.e(`Socket authentication error (401) from ${APIUtils.getSrcIpDefaultRaw(req)}`); - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); - socket.destroy(); - } - } catch (err) { - // Handle authentication error - log.e(`Socket authentication error (500): ${err}\n from ${APIUtils.getSrcIpDefaultRaw(req)}`); - socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n'); - socket.destroy(); - } - });*/ + http.on('error', err => { + log.e(`HTTP error: ${err.stack}`); + }); } catch (err) { log.e(`Cannot start: Network could not be initalized. ${err}`); diff --git a/src/routes/api.ts b/src/routes/api.ts index c6a9b5a..6a6bfc8 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -2,6 +2,8 @@ import { route as VersionCheckRoute } from "./api/versioncheck.ts"; import { route as ConfigRoute } from "./api/config.ts"; import { route as GameConfig } from "./api/gameconfigs.ts"; import { route as PlayerReportingRoute } from "./api/PlayerReporting.ts"; +import { route as MessagesRoute } from "./api/messages.ts"; +import { route as RelationshipsRoute } from "./api/relationships.ts"; import { APIUtils } from "../apiutils.ts"; export const route = APIUtils.createRouter("/api"); @@ -9,4 +11,6 @@ export const route = APIUtils.createRouter("/api"); route.router.use(VersionCheckRoute.path, VersionCheckRoute.router); route.router.use(ConfigRoute.path, ConfigRoute.router); route.router.use(GameConfig.path, GameConfig.router); -route.router.use(PlayerReportingRoute.path, PlayerReportingRoute.router); \ No newline at end of file +route.router.use(PlayerReportingRoute.path, PlayerReportingRoute.router); +route.router.use(MessagesRoute.path, MessagesRoute.router); +route.router.use(RelationshipsRoute.path, RelationshipsRoute.router); \ No newline at end of file diff --git a/src/routes/api/config.ts b/src/routes/api/config.ts index 5adcbda..fd11dd1 100644 --- a/src/routes/api/config.ts +++ b/src/routes/api/config.ts @@ -8,3 +8,10 @@ route.router.get("/v2", (_rq, rs) => { if (config == null) rs.sendStatus(500); else rs.json(config); }); + +route.router.get('/v1/amplitude', + APIUtils.setCacheAllowed, + (_rq, rs) => { + rs.json({AmplitudeKey: ""}); + } +); \ No newline at end of file diff --git a/src/routes/api/messages.ts b/src/routes/api/messages.ts new file mode 100644 index 0000000..48d5f2e --- /dev/null +++ b/src/routes/api/messages.ts @@ -0,0 +1,13 @@ +import { APIUtils } from "../../apiutils.ts"; + +export const route = APIUtils.createRouter("/messages"); + +route.router.get('/v2/get', + + APIUtils.Authentication, + + (_rq, rs) => { + rs.json([]); // temporary + } + +) \ No newline at end of file diff --git a/src/routes/api/players.ts b/src/routes/api/players.ts new file mode 100644 index 0000000..4392524 --- /dev/null +++ b/src/routes/api/players.ts @@ -0,0 +1,17 @@ +import { APIUtils } from "../../apiutils.ts"; + +export const route = APIUtils.createRouter("/players"); + +route.router.get('/v1/progression/:id', + + APIUtils.Authentication, + + async (_rq, rs) => { + rs.json({ + PlayerId: rs.locals.profile.getId(), + Level: await rs.locals.profile.Progression.getLevel(), // await is temporary + Xp: await rs.locals.profile.Progression.getXp() + }); + } + +); \ No newline at end of file diff --git a/src/routes/api/relationships.ts b/src/routes/api/relationships.ts new file mode 100644 index 0000000..f868e09 --- /dev/null +++ b/src/routes/api/relationships.ts @@ -0,0 +1,15 @@ +import { APIUtils } from "../../apiutils.ts"; +import { AuthType } from "../../data/users.ts"; + +export const route = APIUtils.createRouter("/relationships"); + +route.router.get('/v2/get', + + APIUtils.Authentication, + APIUtils.AuthenticationType(AuthType.Game), + + (_rq, rs) => { + rs.json([]); // temporary + } + +); \ No newline at end of file diff --git a/src/routes/auth/cachedlogin.ts b/src/routes/auth/cachedlogin.ts index 2b4bad7..e0599c1 100644 --- a/src/routes/auth/cachedlogin.ts +++ b/src/routes/auth/cachedlogin.ts @@ -1,10 +1,12 @@ import { APIUtils } from "../../apiutils.ts"; +import { AuthType } from "../../data/users.ts"; export const route = APIUtils.createRouter("/cachedlogin"); route.router.get("/forplatformid/:platformtype/:platformid", APIUtils.Authentication, + APIUtils.AuthenticationType(AuthType.Web), async (_rq, rs) => { const profiles = await rs.locals.user.exportAssociatedProfiles(); diff --git a/src/routes/auth/connect.ts b/src/routes/auth/connect.ts index 613c388..b97d28e 100644 --- a/src/routes/auth/connect.ts +++ b/src/routes/auth/connect.ts @@ -5,6 +5,7 @@ import { decode } from "@gz/jwt"; import { Config } from "../../config.ts"; import Logging from "@proxnet/undead-logging"; import { z } from "zod"; +import { AuthType } from "../../data/users.ts"; const config = Config.getConfig(); @@ -75,6 +76,7 @@ interface TokenResponseBody { route.router.post("/token", APIUtils.Authentication, + APIUtils.AuthenticationType(AuthType.Web), express.urlencoded({ extended: true }), APIUtils.validateRequestBody(TokenRequestBodySchema), @@ -149,6 +151,9 @@ route.router.post("/token", return; } + const details = await profile.export(); + log.i(`Player ${details?.username} "${details?.displayName}" (${profile.getId()}) logged in`); + const token = await profile.getToken(); rs.json({ access_token: token, diff --git a/src/routes/match/player.ts b/src/routes/match/player.ts index 2413301..72923a6 100644 --- a/src/routes/match/player.ts +++ b/src/routes/match/player.ts @@ -3,6 +3,7 @@ import { APIUtils, NoBody } from "../../apiutils.ts"; import express from "express"; import Matchmaking from "../../data/live/base.ts"; import Presence from "../../data/live/presence.ts"; +import { AuthType } from "../../data/users.ts"; export const route = APIUtils.createRouter('/player'); @@ -17,6 +18,7 @@ const LoginSchema = z.object({ route.router.post('/login', APIUtils.Authentication, + APIUtils.AuthenticationType(AuthType.Game), express.urlencoded({extended: true}), APIUtils.validateRequestBody(LoginSchema), diff --git a/src/routes/nameserver.ts b/src/routes/nameserver.ts index f6399ff..c3cce31 100644 --- a/src/routes/nameserver.ts +++ b/src/routes/nameserver.ts @@ -2,7 +2,7 @@ import { APIUtils } from "../apiutils.ts"; import { Config } from "../config.ts"; const config = Config.getConfig() as Config.GalvanicConfiguration; -const protocol = config.web.securepublichost ? "https" : "http"; +const protocol = config.web.api.securepublichost ? "https" : "http"; export const route = APIUtils.createRouter("/ns"); @@ -21,17 +21,17 @@ type NameserverHosts = { }; const nameserver: NameserverHosts = { - Auth: `${protocol}://${config.web.publichost}/auth`, - API: `${protocol}://${config.web.publichost}`, - WWW: `${protocol}://${config.web.publichost}`, - Notifications: `${protocol}://${config.web.publichost}/notify`, - Images: `${protocol}://${config.web.publichost}/img`, - CDN: `${protocol}://${config.web.publichost}/cdn`, - Commerce: `${protocol}://${config.web.publichost}/commerce`, - Matchmaking: `${protocol}://${config.web.publichost}/match`, - Storage: `${protocol}://${config.web.publichost}/storage`, - Chat: `${protocol}://${config.web.publichost}/chat`, - Leaderboard: `${protocol}://${config.web.publichost}/leaderboard`, + Auth: `${protocol}://${config.web.api.publichost}/auth`, + API: `${protocol}://${config.web.api.publichost}`, + WWW: `${protocol}://${config.web.api.publichost}`, + Notifications: `${protocol}://${config.web.api.publichost}/notify`, + Images: `${protocol}://${config.web.api.publichost}/img`, + CDN: `${protocol}://${config.web.api.publichost}/cdn`, + Commerce: `${protocol}://${config.web.api.publichost}/commerce`, + Matchmaking: `${protocol}://${config.web.api.publichost}/match`, + Storage: `${protocol}://${config.web.api.publichost}/storage`, + Chat: `${protocol}://${config.web.api.publichost}/chat`, + Leaderboard: `${protocol}://${config.web.api.publichost}/leaderboard`, }; route.router.get("*", (_rq, rs) => { diff --git a/src/socket/route.ts b/src/socket/route.ts index 4679afe..c454167 100644 --- a/src/socket/route.ts +++ b/src/socket/route.ts @@ -1,17 +1,27 @@ import { APIUtils } from "../apiutils.ts"; +import { Config } from "../config.ts"; +import { AuthType } from "../data/users.ts"; import { SocketHandoff } from "./handoff.ts"; +import express from "express"; + +const config = Config.getConfig(); export const route = APIUtils.createRouter('/notify'); route.router.post('/hub/v1/negotiate', APIUtils.Authentication, + APIUtils.AuthenticationType(AuthType.Game), + express.urlencoded({ extended: true }), + APIUtils.logBody, (_rq, rs) => { const handoff = new SocketHandoff(); rs.json({ connectionId: handoff.id, - availableTransports: [{transport:"WebSockets",transferFormats:["Text"]}] + availableTransports: [{transport:"WebSockets",transferFormats:["Text"]}], + url: `${config.web.socket.securepublichost ? 'https' : 'http'}://${config.web.socket.publichost}/`, + accessToken: rs.locals.token }); }, diff --git a/src/socket/socket.ts b/src/socket/socket.ts index b8efdfb..c0b7800 100644 --- a/src/socket/socket.ts +++ b/src/socket/socket.ts @@ -1,43 +1,77 @@ -import WebSocket from "ws"; import { Profile } from "../data/profiles.ts"; import Logging from "@proxnet/undead-logging"; +import { Message, MessageKind, SignalMessageType, SignalRMessage, SignalRMessageSchema, TargetResult, TargetResultFailure, TargetResultSuccess, TargetResultType } from "./types.ts"; +import { SocketTarget } from "./targets/targetbase.ts"; +import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; export class SignalRSocketHandler { - log: Logging = new Logging("SignalMock-"); + #log: Logging = new Logging("SignalMock-"); #socket: WebSocket; #profile: Profile; + #Targets: Map = new Map(); + constructor(socket: WebSocket, player: Profile) { this.#socket = socket; - this.#initLogSource(); - this.#profile = player; + this.#init(); + player.setSocketHandler(this); - Deno.addSignalListener('SIGINT', this.destroy); + this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget()); } - destroy() { - this.#socket.close(); - Deno.removeSignalListener('SIGINT', this.destroy); + async #dispatchTarget(target: string, args: object[]): Promise { + const targetExec = this.#Targets.get(target); + if (!targetExec) return { type: TargetResultType.Failure } as TargetResultFailure; + else return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess; } - async #initLogSource() { - this.log.source += this.#profile.getId().toString(); + #onMessage(message: Message) { + if (message.kind == MessageKind.Protocol) { + this.#send({}); + return; + } else { + this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type - 1]})\n ${JSON.stringify(message.data)}`); + } + } - this.log.i(`Player '${(await this.#profile.export())?.username}' (${this.#profile.getId()}) created hub socket`); + async #init() { + this.#log.source += this.#profile.getId().toString(); - this.#socket.on('open', () => { - this.log.d(`hello world`) - }); - this.#socket.on('message', data => { - this.log.d(data.toString()); + this.#log.i(`Player '${(await this.#profile.export())?.username}' (${this.#profile.getId()}) created hub socket`); + + this.#socket.addEventListener('message', message => { + try { + + const dec = new TextDecoder(); + const str = dec.decode(message.data); + const data = JSON.parse(str.substring(0, str.length - 1)); + + const parseResult = SignalRMessageSchema.safeParse(data); + if (parseResult.success) this.#onMessage({ + kind: MessageKind.Data, + data: parseResult.data as SignalRMessage + }); + else { + this.#onMessage({ + kind: MessageKind.Protocol + }); + } + + } catch (err) { + this.#log.e(`Socket error: ${err}`); + } }); } + #send(data: object) { + this.#socket.send(`${JSON.stringify(data)}\u001e`); + } + } \ No newline at end of file diff --git a/src/socket/targets/SubscribeToPlayers.ts b/src/socket/targets/SubscribeToPlayers.ts new file mode 100644 index 0000000..1a2593e --- /dev/null +++ b/src/socket/targets/SubscribeToPlayers.ts @@ -0,0 +1,16 @@ +import { SocketTarget } from "./targetbase.ts"; + +export class PlayerSocketSubscriptionTarget extends SocketTarget { + + subscriptions: number[] = []; + + setSubscriptions(subs: number[]) { + this.subscriptions = subs; + } + + // deno-lint-ignore require-await + override async exec(_args: (object | string | number | boolean)[]) { + return; + } + +} \ No newline at end of file diff --git a/src/socket/targets/targetbase.ts b/src/socket/targets/targetbase.ts new file mode 100644 index 0000000..5756552 --- /dev/null +++ b/src/socket/targets/targetbase.ts @@ -0,0 +1,20 @@ +export class SocketTarget { + + profileNotSetError = new Error("The profile on this target is not set."); + + profileId: number | null = null; + + setProfile(id: number) { + this.profileId = id; + } + + profileIsSet() { + return this.profileId !== null; + } + + // deno-lint-ignore require-await + async exec(_args: (object | string | number | boolean)[]) { + throw new Error("Execution for this target is not set."); + } + +} \ No newline at end of file diff --git a/src/socket/types.ts b/src/socket/types.ts index 79cee25..c25a5f3 100644 --- a/src/socket/types.ts +++ b/src/socket/types.ts @@ -1,22 +1,125 @@ -export enum MessageTypes { - CancelInvocation, - Close, - Completion, +import { z } from "zod"; + +export enum MessageKind { + Protocol, + Data +} +interface MessageBase { + kind: MessageKind +} +interface DataMessage extends MessageBase { + kind: MessageKind.Data, + data: SignalRMessage +} +interface ProtocolMessage extends MessageBase { + kind: MessageKind.Protocol +} +export type Message = ProtocolMessage | DataMessage; + +export type SignalRMessage = + | InvocationMessage + | StreamItemMessage + | CompletionMessage + | PingMessage + | CloseMessage; + +export enum SignalMessageType { Handshake, Invocation, - Ping, - StreamInvocation, StreamItem, - Ack + Completion, + StreamInvocation, + CancelInvocation, + Ping, + Close } -export interface SignalRMessage { - arguments: object[], - error?: string, - invocationId?: string, - item?: object, - nonblocking: boolean, - result?: object, - target: string, - type: MessageTypes -} \ No newline at end of file +interface BaseMessage { + type: SignalMessageType; +} + +interface InvocationMessage extends BaseMessage { + type: SignalMessageType.Invocation; + target: string; + arguments: unknown[]; + invocationId?: string; +} + +interface StreamItemMessage extends BaseMessage { + type: SignalMessageType.StreamItem; + invocationId: string; + item: unknown; +} + +interface CompletionMessage extends BaseMessage { + type: SignalMessageType.Completion; + invocationId: string; + result?: unknown; + error?: string; +} + +interface PingMessage extends BaseMessage { + type: SignalMessageType.Ping; +} + +interface CloseMessage extends BaseMessage { + type: SignalMessageType.Close; + error?: string; +} + +const BaseMessageSchema = z.object({ + type: z.nativeEnum(SignalMessageType), +}); + +const InvocationMessageSchema = BaseMessageSchema.extend({ + type: z.literal(SignalMessageType.Invocation), + target: z.string(), + arguments: z.array(z.unknown()), + invocationId: z.string().optional(), +}); + +const StreamItemMessageSchema = BaseMessageSchema.extend({ + type: z.literal(SignalMessageType.StreamItem), + invocationId: z.string(), + item: z.unknown(), +}); + +const CompletionMessageSchema = BaseMessageSchema.extend({ + type: z.literal(SignalMessageType.Completion), + invocationId: z.string(), + result: z.unknown().optional(), + error: z.string().optional(), +}); + +const PingMessageSchema = BaseMessageSchema.extend({ + type: z.literal(SignalMessageType.Ping), +}); + +const CloseMessageSchema = BaseMessageSchema.extend({ + type: z.literal(SignalMessageType.Close), + error: z.string().optional(), +}); + +export const SignalRMessageSchema = z.discriminatedUnion("type", [ + InvocationMessageSchema, + StreamItemMessageSchema, + CompletionMessageSchema, + PingMessageSchema, + CloseMessageSchema, +]); + +export enum TargetResultType { + Success, + Failure +} +interface TargetResultBase { + type: TargetResultType +} +export interface TargetResultSuccess extends TargetResultBase { + type: TargetResultType.Success, + data: T +} +export interface TargetResultFailure extends TargetResultBase { + type: TargetResultType.Failure +} +export type TargetResult = TargetResultSuccess | TargetResultFailure; \ No newline at end of file diff --git a/src/types/express.ts b/src/types/express.ts index 748c429..d1da37d 100644 --- a/src/types/express.ts +++ b/src/types/express.ts @@ -6,6 +6,7 @@ declare global { interface Locals { profile: Profile; user: User; + token: string | undefined; } } }