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)
This commit is contained in:
2025-03-29 01:59:28 -04:00
parent 6b97e3800a
commit 6aae9129b5
28 changed files with 529 additions and 148 deletions

View File

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

8
deno.lock generated
View File

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

View File

@@ -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<Deno.NetAddr>) {
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<string | number | symbol, never>;
export * as APIUtils from "./apiutils.ts";

View File

@@ -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,11 +73,19 @@ export const defaultConfig: GalvanicConfiguration = {
db: 0,
},
web: {
port: 3000,
api: {
port: 13370,
host: "127.0.0.1",
publichost: "127.0.0.1:3000",
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",
serverId: "galvanic-corrosion-default",

View File

@@ -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;

View File

@@ -55,7 +55,7 @@ class PlayerPresence {
const PlayerStatusVisibilityEnum = z.nativeEnum(PlayerStatusVisibility);
type PlayerStatusVisibilityEnum = z.infer<typeof PlayerStatusVisibilityEnum>;
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;
}

View File

@@ -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;
}
}

View File

@@ -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<number, number> = 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
}
}

View File

@@ -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));
}
}

View File

@@ -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<typeof VRMovementModeEnum>
@@ -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();

View File

@@ -1,4 +0,0 @@
export interface Setting {
Key: string;
Value: string;
}

View File

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

View File

@@ -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];
@@ -119,12 +124,25 @@ try {
else return { token: decodedToken, valid: true } as AuthResult;
}
const wss = new WebSocketServer({ noServer: true, path: "/notify/hub/v1" });
wss.on('connection', (socket: WebSocket, req: IncomingMessage) => {
if (!req.token) {
socket.close();
return;
}
const abort = new AbortController();
// 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<Deno.NetAddr>) => {
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;
@@ -136,11 +154,21 @@ try {
}
if (handoff) handoff.complete();
new SignalRSocketHandler(socket, UnifiedProfile.get(req.token.sub));
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 });
}
});
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);
http.on('error', err => {
log.e(`HTTP error: ${err.stack}`);
});
} 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();
}
});*/
} catch (err) {
log.e(`Cannot start: Network could not be initalized. ${err}`);

View File

@@ -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");
@@ -10,3 +12,5 @@ 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);
route.router.use(MessagesRoute.path, MessagesRoute.router);
route.router.use(RelationshipsRoute.path, RelationshipsRoute.router);

View File

@@ -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: ""});
}
);

View File

@@ -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
}
)

17
src/routes/api/players.ts Normal file
View File

@@ -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()
});
}
);

View File

@@ -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
}
);

View File

@@ -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();

View File

@@ -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<AuthBodyBase>(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,

View File

@@ -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),

View File

@@ -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) => {

View File

@@ -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
});
},

View File

@@ -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<string, SocketTarget> = 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<T = unknown>(target: string, args: object[]): Promise<TargetResult> {
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<T>;
}
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.#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
});
this.#socket.on('message', data => {
this.log.d(data.toString());
else {
this.#onMessage({
kind: MessageKind.Protocol
});
}
} catch (err) {
this.#log.e(`Socket error: ${err}`);
}
});
}
#send(data: object) {
this.#socket.send(`${JSON.stringify(data)}\u001e`);
}
}

View File

@@ -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;
}
}

View File

@@ -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.");
}
}

View File

@@ -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
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<T = unknown> extends TargetResultBase {
type: TargetResultType.Success,
data: T
}
export interface TargetResultFailure extends TargetResultBase {
type: TargetResultType.Failure
}
export type TargetResult = TargetResultSuccess | TargetResultFailure;

View File

@@ -6,6 +6,7 @@ declare global {
interface Locals {
profile: Profile;
user: User;
token: string | undefined;
}
}
}