Many changes. Commit before I break down.
- Authentication middleware uses Zod - PhotonRegionId in config - DB key changes and additions - WebSocket for SignalR mock - Presence additions * Needs modification for playerIds (do not store `Profile` in a set, this will cause sync issues) - Profile settings - Profile Device Class - Zod properly checks for issuer in token - Room scene type bug - Setting key import started - Instancing changes - PlayerReporting API route - Deduplicated auth/connect/token - match/player/login begin - WebSocket hands off connection to SignalR handler
This commit is contained in:
@@ -9,14 +9,17 @@
|
||||
"@gz/jwt": "jsr:@gz/jwt@^0.1.0",
|
||||
"@proxnet/undead-logging": "jsr:@proxnet/undead-logging@^1.2.0",
|
||||
"@std/assert": "jsr:@std/assert@1",
|
||||
"@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/validator": "npm:@types/validator@^13.12.2",
|
||||
"@types/ws": "npm:@types/ws@^8.18.0",
|
||||
"cookie-parser": "npm:cookie-parser@^1.4.7",
|
||||
"discord.js": "npm:discord.js@^14.16.3",
|
||||
"express": "npm:express@^4.21.2",
|
||||
"ioredis": "npm:ioredis@^5.5.0",
|
||||
"validator": "npm:validator@^13.12.0",
|
||||
"ws": "npm:ws@^8.18.1",
|
||||
"zod": "npm:zod@^3.24.2"
|
||||
},
|
||||
"files": [],
|
||||
|
||||
87
deno.lock
generated
87
deno.lock
generated
@@ -5,8 +5,17 @@
|
||||
"jsr:@proxnet/undead-logging@^1.2.0": "1.2.0",
|
||||
"jsr:@std/assert@1": "1.0.7",
|
||||
"jsr:@std/bytes@^1.0.2": "1.0.4",
|
||||
"jsr:@std/cli@^1.0.12": "1.0.15",
|
||||
"jsr:@std/crypto@^1.0.3": "1.0.3",
|
||||
"jsr:@std/encoding@^1.0.7": "1.0.8",
|
||||
"jsr:@std/fmt@^1.0.5": "1.0.6",
|
||||
"jsr:@std/html@^1.0.3": "1.0.3",
|
||||
"jsr:@std/http@^1.0.13": "1.0.13",
|
||||
"jsr:@std/internal@^1.0.5": "1.0.5",
|
||||
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
||||
"jsr:@std/net@^1.0.4": "1.0.4",
|
||||
"jsr:@std/path@^1.0.8": "1.0.8",
|
||||
"jsr:@std/streams@^1.0.9": "1.0.9",
|
||||
"jsr:@std/uuid@*": "1.0.4",
|
||||
"npm:@imagemagick/magick-wasm@0.0.31": "0.0.31",
|
||||
"npm:@types/cookie-parser@*": "1.4.8_@types+express@5.0.0",
|
||||
@@ -15,12 +24,14 @@
|
||||
"npm:@types/express@5": "5.0.0",
|
||||
"npm:@types/node@*": "22.5.4",
|
||||
"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",
|
||||
"npm:cookie-parser@^1.4.7": "1.4.7",
|
||||
"npm:discord.js@^14.16.3": "14.16.3",
|
||||
"npm:express@^4.21.2": "4.21.2",
|
||||
"npm:ioredis@^5.5.0": "5.5.0",
|
||||
"npm:validator@^13.12.0": "13.12.0",
|
||||
"npm:ws@^8.18.1": "8.18.1",
|
||||
"npm:zod@^3.24.2": "3.24.2"
|
||||
},
|
||||
"jsr": {
|
||||
@@ -42,12 +53,49 @@
|
||||
"@std/bytes@1.0.4": {
|
||||
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
|
||||
},
|
||||
"@std/cli@1.0.15": {
|
||||
"integrity": "e79ba3272ec710ca44d8342a7688e6288b0b88802703f3264184b52893d5e93f"
|
||||
},
|
||||
"@std/crypto@1.0.3": {
|
||||
"integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f"
|
||||
},
|
||||
"@std/encoding@1.0.8": {
|
||||
"integrity": "a6c8f3f933ab1bed66244f435d1dc0fd23a888e07195532122ddc3d5f8f0e6b4"
|
||||
},
|
||||
"@std/fmt@1.0.6": {
|
||||
"integrity": "a2c56a69a2369876ddb3ad6a500bb6501b5bad47bb3ea16bfb0c18974d2661fc"
|
||||
},
|
||||
"@std/html@1.0.3": {
|
||||
"integrity": "7a0ac35e050431fb49d44e61c8b8aac1ebd55937e0dc9ec6409aa4bab39a7988"
|
||||
},
|
||||
"@std/http@1.0.13": {
|
||||
"integrity": "d29618b982f7ae44380111f7e5b43da59b15db64101198bb5f77100d44eb1e1e",
|
||||
"dependencies": [
|
||||
"jsr:@std/cli",
|
||||
"jsr:@std/encoding",
|
||||
"jsr:@std/fmt",
|
||||
"jsr:@std/html",
|
||||
"jsr:@std/media-types",
|
||||
"jsr:@std/net",
|
||||
"jsr:@std/path",
|
||||
"jsr:@std/streams"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.5": {
|
||||
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
|
||||
},
|
||||
"@std/media-types@1.1.0": {
|
||||
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||
},
|
||||
"@std/net@1.0.4": {
|
||||
"integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852"
|
||||
},
|
||||
"@std/path@1.0.8": {
|
||||
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||
},
|
||||
"@std/streams@1.0.9": {
|
||||
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
|
||||
},
|
||||
"@std/uuid@1.0.4": {
|
||||
"integrity": "f4233149cc8b4753cc3763fd83a7c4101699491f55c7be78dc7b30281946d7a0",
|
||||
"dependencies": [
|
||||
@@ -105,11 +153,11 @@
|
||||
"@discordjs/rest",
|
||||
"@discordjs/util",
|
||||
"@sapphire/async-queue",
|
||||
"@types/ws",
|
||||
"@types/ws@8.5.13",
|
||||
"@vladfrangu/async_event_emitter",
|
||||
"discord-api-types@0.37.83",
|
||||
"tslib",
|
||||
"ws"
|
||||
"ws@8.18.0"
|
||||
]
|
||||
},
|
||||
"@imagemagick/magick-wasm@0.0.31": {
|
||||
@@ -135,13 +183,13 @@
|
||||
"integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
|
||||
"dependencies": [
|
||||
"@types/connect",
|
||||
"@types/node"
|
||||
"@types/node@22.5.4"
|
||||
]
|
||||
},
|
||||
"@types/connect@3.4.38": {
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
"@types/node@22.5.4"
|
||||
]
|
||||
},
|
||||
"@types/cookie-parser@1.4.8_@types+express@5.0.0": {
|
||||
@@ -153,7 +201,7 @@
|
||||
"@types/express-serve-static-core@5.0.1": {
|
||||
"integrity": "sha512-CRICJIl0N5cXDONAdlTv5ShATZ4HEwk6kDDIW2/w9qOWKg+NU/5F8wYRWCrONad0/UKkloNSmmyN/wX4rtpbVA==",
|
||||
"dependencies": [
|
||||
"@types/node",
|
||||
"@types/node@22.5.4",
|
||||
"@types/qs",
|
||||
"@types/range-parser",
|
||||
"@types/send"
|
||||
@@ -174,10 +222,16 @@
|
||||
"@types/mime@1.3.5": {
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
|
||||
},
|
||||
"@types/node@22.12.0": {
|
||||
"integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==",
|
||||
"dependencies": [
|
||||
"undici-types@6.20.0"
|
||||
]
|
||||
},
|
||||
"@types/node@22.5.4": {
|
||||
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
|
||||
"dependencies": [
|
||||
"undici-types"
|
||||
"undici-types@6.19.8"
|
||||
]
|
||||
},
|
||||
"@types/qs@6.9.17": {
|
||||
@@ -190,24 +244,30 @@
|
||||
"integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
|
||||
"dependencies": [
|
||||
"@types/mime",
|
||||
"@types/node"
|
||||
"@types/node@22.5.4"
|
||||
]
|
||||
},
|
||||
"@types/serve-static@1.15.7": {
|
||||
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||
"dependencies": [
|
||||
"@types/http-errors",
|
||||
"@types/node",
|
||||
"@types/node@22.5.4",
|
||||
"@types/send"
|
||||
]
|
||||
},
|
||||
"@types/validator@13.12.2": {
|
||||
"integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA=="
|
||||
},
|
||||
"@types/ws@8.18.0": {
|
||||
"integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
|
||||
"dependencies": [
|
||||
"@types/node@22.12.0"
|
||||
]
|
||||
},
|
||||
"@types/ws@8.5.13": {
|
||||
"integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
"@types/node@22.5.4"
|
||||
]
|
||||
},
|
||||
"@vladfrangu/async_event_emitter@2.4.6": {
|
||||
@@ -694,6 +754,9 @@
|
||||
"undici-types@6.19.8": {
|
||||
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="
|
||||
},
|
||||
"undici-types@6.20.0": {
|
||||
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
|
||||
},
|
||||
"undici@6.19.8": {
|
||||
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g=="
|
||||
},
|
||||
@@ -712,6 +775,9 @@
|
||||
"ws@8.18.0": {
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="
|
||||
},
|
||||
"ws@8.18.1": {
|
||||
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="
|
||||
},
|
||||
"zod@3.24.2": {
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ=="
|
||||
}
|
||||
@@ -862,14 +928,17 @@
|
||||
"jsr:@gz/jwt@0.1",
|
||||
"jsr:@proxnet/undead-logging@^1.2.0",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/http@^1.0.13",
|
||||
"npm:@types/cookie-parser@^1.4.8",
|
||||
"npm:@types/express@5",
|
||||
"npm:@types/validator@^13.12.2",
|
||||
"npm:@types/ws@^8.18.0",
|
||||
"npm:cookie-parser@^1.4.7",
|
||||
"npm:discord.js@^14.16.3",
|
||||
"npm:express@^4.21.2",
|
||||
"npm:ioredis@^5.5.0",
|
||||
"npm:validator@^13.12.0",
|
||||
"npm:ws@^8.18.1",
|
||||
"npm:zod@^3.24.2"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@ type genericResponse = {
|
||||
data?: object
|
||||
}
|
||||
|
||||
export function generateMask(...num: number[]) {
|
||||
return num.reduce((sum, val) => sum + val, 0);
|
||||
}
|
||||
|
||||
export function genericResponseFormat(
|
||||
failure: boolean,
|
||||
msg?: string,
|
||||
@@ -118,7 +122,7 @@ export function emptyArrayResponse(_rq: express.Request, rs: express.Response) {
|
||||
rs.json([]);
|
||||
}
|
||||
|
||||
export function getSrcIpDefault(rq: express.Request) {
|
||||
export function getSrcIpDefault(rq: express.Request): string {
|
||||
const cfIp = rq.header("cf-connecting-ip");
|
||||
if (cfIp !== undefined) return cfIp;
|
||||
|
||||
@@ -208,6 +212,24 @@ export interface TokenBaseFormat {
|
||||
}
|
||||
export type TokenFormat = UserTokenFormat | ProfileTokenFormat;
|
||||
|
||||
const TokenBaseSchema = z.object({
|
||||
typ: z.nativeEnum(AuthType),
|
||||
iss: z.string().url(),
|
||||
exp: z.number()
|
||||
});
|
||||
export const UserTokenSchema = TokenBaseSchema.extend({
|
||||
sub: z.string(),
|
||||
typ: z.literal(AuthType.Web)
|
||||
});
|
||||
export const ProfileTokenSchema = TokenBaseSchema.extend({
|
||||
sub: z.number(),
|
||||
typ: z.literal(AuthType.Game)
|
||||
});
|
||||
export const TokenSchema = z.discriminatedUnion('typ', [
|
||||
UserTokenSchema,
|
||||
ProfileTokenSchema
|
||||
]);
|
||||
|
||||
export async function Authentication(
|
||||
rq: express.Request,
|
||||
rs: express.Response,
|
||||
@@ -238,16 +260,15 @@ export async function Authentication(
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedToken = await decode<TokenFormat>(
|
||||
token,
|
||||
config.auth.secret,
|
||||
{
|
||||
algorithm: "HS512",
|
||||
},
|
||||
);
|
||||
const decodedToken = await decode<TokenFormat>(token, config.auth.secret, {algorithm: "HS512"});
|
||||
const schemaResult = TokenSchema.safeParse(decodedToken);
|
||||
if (!schemaResult.success) {
|
||||
returnUnauthorized();
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = ![
|
||||
decodedToken.iss == config.web.publichost,
|
||||
const valid = ![ // used to contain more conditions, now is only 1
|
||||
decodedToken.iss == `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`,
|
||||
].includes(false);
|
||||
if (valid) {
|
||||
if (decodedToken.typ == AuthType.Web) rs.locals.user = new User(decodedToken.sub);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Logging from "@proxnet/undead-logging";
|
||||
import * as fs from "node:fs";
|
||||
import { PhotonRegionCodeString } from "./data/live/types.ts";
|
||||
|
||||
const log = new Logging("Config");
|
||||
|
||||
@@ -26,6 +27,7 @@ type PublicConfiguration = {
|
||||
levelScale: number;
|
||||
maxLevels: number;
|
||||
patches: string[];
|
||||
photonRegionId: PhotonRegionCodeString;
|
||||
};
|
||||
|
||||
type LoggingConfiguration = {
|
||||
@@ -79,6 +81,7 @@ export const defaultConfig: GalvanicConfiguration = {
|
||||
levelScale: 1,
|
||||
maxLevels: 30,
|
||||
patches: [],
|
||||
photonRegionId: PhotonRegionCodeString.UnitedStates,
|
||||
},
|
||||
logging: {
|
||||
notfound: false,
|
||||
|
||||
@@ -83,6 +83,16 @@ export interface Room {
|
||||
DisableMicAutoMute?: boolean
|
||||
}
|
||||
|
||||
export enum RoomWarningMask {
|
||||
None,
|
||||
Scary,
|
||||
Mature = 2,
|
||||
FlashingLights = 4,
|
||||
IntenseMotion = 8,
|
||||
Violence = 16,
|
||||
Custom = 32
|
||||
}
|
||||
|
||||
export enum TagType {
|
||||
General,
|
||||
Auto,
|
||||
@@ -109,7 +119,7 @@ export interface RoomScene {
|
||||
|
||||
export interface RoomDetails {
|
||||
Room: Room,
|
||||
Scenes: RoomScene,
|
||||
Scenes: RoomScene[],
|
||||
CoOwners: number[],
|
||||
InvitedCoOwners: number[],
|
||||
Moderators?: number[],
|
||||
|
||||
9
src/data/content/settings.ts
Normal file
9
src/data/content/settings.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum SettingKey {
|
||||
PlayerStatusVisibility = "PlayerStatusVisibility",
|
||||
VRMovementMode = "VR_MOVEMENT_MODE",
|
||||
}
|
||||
|
||||
export enum SettingDefault {
|
||||
PlayerStatusVisibility = "1",
|
||||
VRMovementMode = "0"
|
||||
}
|
||||
@@ -1,37 +1,13 @@
|
||||
import Logging from "@proxnet/undead-logging";
|
||||
import Profile from "../profiles.ts";
|
||||
import { IntegratedRoomScene, RoomDetails } from "../content/roomtypes.ts";
|
||||
import { RoomInstance, InstanceOptions } from "./types.ts";
|
||||
import { Config } from "../../config.ts";
|
||||
|
||||
const log = new Logging("Instances");
|
||||
|
||||
export interface RoomInstance {
|
||||
|
||||
roomInstanceId: number,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
location: IntegratedRoomScene,
|
||||
dataBlob?: string,
|
||||
eventId?: number,
|
||||
photonRegionId: "us",
|
||||
photonRoomId: string,
|
||||
name?: string,
|
||||
maxCapacity: number,
|
||||
isFull: boolean,
|
||||
isPrivate: boolean,
|
||||
isInProgress: boolean
|
||||
|
||||
}
|
||||
|
||||
interface InstanceOptions {
|
||||
|
||||
Room: RoomDetails,
|
||||
SceneIndex?: number,
|
||||
EventId?: number,
|
||||
Name?: string,
|
||||
Private?: boolean
|
||||
|
||||
}
|
||||
const config = Config.getConfig();
|
||||
|
||||
// `Profile` isn't synchronized. Fix this.
|
||||
const instancePlayers: Map<RoomInstance, Set<Profile>> = new Map();
|
||||
/**
|
||||
* `Map<roomId (number), RoomInstance>`
|
||||
@@ -101,10 +77,55 @@ class InstancesBase {
|
||||
return this.getInstancePlayers(instance).size < instance.maxCapacity;
|
||||
}
|
||||
|
||||
createInstance(options: InstanceOptions) {
|
||||
// todo: use room data to create room instance
|
||||
#generateUniqueInstanceId() {
|
||||
let newInstanceId = Math.round(Math.random() * Math.pow(2, 31))
|
||||
const allInstances = this.getAllInstances();
|
||||
while (Array.from(allInstances.values()).map(val => val.roomInstanceId).includes(newInstanceId)) newInstanceId = Math.round(Math.random() * Math.pow(2, 31));
|
||||
return newInstanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance with options.
|
||||
*
|
||||
* If `options.FirstPlayer` is not specified, the created instance will not contain any players and may be removed.
|
||||
*
|
||||
* If one is, the player will be automatically added to the instance and their `profile.getInstance()` will be synchronized.
|
||||
*/
|
||||
createInstance(options: InstanceOptions) {
|
||||
|
||||
const scene = options.Room.Scenes[options.SceneIndex];
|
||||
const newId = this.#generateUniqueInstanceId();
|
||||
if (!scene) throw new Error("The specified scene did not exist.");
|
||||
|
||||
const newInstance: RoomInstance = {
|
||||
roomInstanceId: newId,
|
||||
roomId: options.Room.Room.RoomId,
|
||||
subRoomId: scene.RoomSceneId,
|
||||
location: scene.RoomSceneLocationId,
|
||||
dataBlob: scene.DataBlobName == "" ? undefined : scene.DataBlobName,
|
||||
eventId: options.EventId,
|
||||
photonRegionId: config.public.photonRegionId,
|
||||
photonRoomId: `20191120-GC${newId}`,
|
||||
name: scene.Name === "Home" ? `^${options.Room.Room.Name}` : `^${options.Room.Room.Name}.${scene.Name}`,
|
||||
maxCapacity: scene.MaxPlayers,
|
||||
isFull: false,
|
||||
isPrivate: typeof options.Private !== 'boolean' ? false : options.Private,
|
||||
isInProgress: false
|
||||
};
|
||||
|
||||
this.getAllRoomInstances(options.Room.Room.RoomId).add(newInstance);
|
||||
if (options.FirstPlayer) {
|
||||
this.setPlayerInstance(options.FirstPlayer, newInstance);
|
||||
this.getInstancePlayers(newInstance).add(options.FirstPlayer);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Call only when the player is ready to be moved to an instance
|
||||
*
|
||||
* Synchronizes profile instance to `instance` and adds player to instance.
|
||||
*/
|
||||
setPlayerInstance(player: Profile, instance: RoomInstance) {
|
||||
const currentInstance = player.getInstance();
|
||||
if (currentInstance === instance) return;
|
||||
@@ -128,6 +149,19 @@ class InstancesBase {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Call only when the player is ready to be removed (or when not responding)
|
||||
*
|
||||
* Synchronizes profile instance to `null` and removes player from instance.
|
||||
*/
|
||||
removePlayerFromCurrentInstance(player: Profile) {
|
||||
const instance = player.getInstance();
|
||||
if (!instance) return;
|
||||
this.getInstancePlayers(instance).delete(player);
|
||||
player.setInstance(null);
|
||||
this.updateSingleInstanceIsFull(instance);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const Instances = new InstancesBase();
|
||||
|
||||
@@ -1,10 +1,160 @@
|
||||
import { z } from "zod";
|
||||
import { SettingKey } from "../content/settings.ts";
|
||||
import Profile from "../profiles.ts";
|
||||
import { DeviceClass, PlayerStatusVisibility, RoomInstance, VRMovementMode } from "./types.ts";
|
||||
import Logging from "@proxnet/undead-logging";
|
||||
|
||||
const log = new Logging("Presence");
|
||||
|
||||
interface PresenceExport {
|
||||
roomInstance: RoomInstance | null;
|
||||
playerId: number;
|
||||
statusVisibility: PlayerStatusVisibility;
|
||||
deviceClass: DeviceClass;
|
||||
vrMovementMode?: VRMovementMode;
|
||||
}
|
||||
|
||||
// Hot mess
|
||||
class PlayerPresence {
|
||||
|
||||
intervalId: number;
|
||||
|
||||
#profile: Profile;
|
||||
|
||||
constructor(player: Profile, offline: boolean | undefined = false) {
|
||||
|
||||
this.offline = offline;
|
||||
this.#profile = player;
|
||||
this.playerId = player.getId();
|
||||
this.roomInstance = this.#profile.getInstance();
|
||||
|
||||
if (offline) this.statusVisibility = PlayerStatusVisibility.Offline;
|
||||
else this.statusVisibility = PlayerStatusVisibility.FriendsOnly;
|
||||
this.deviceClass = DeviceClass.Unknown;
|
||||
this.vrMovementMode = VRMovementMode.Teleport;
|
||||
|
||||
this.lastSeen = new Date();
|
||||
|
||||
this.intervalId = setInterval(() => {
|
||||
this.updateOffline();
|
||||
}, 80000);
|
||||
|
||||
}
|
||||
|
||||
offline: boolean;
|
||||
|
||||
playerId: number;
|
||||
statusVisibility: PlayerStatusVisibility;
|
||||
deviceClass: DeviceClass;
|
||||
vrMovementMode: VRMovementMode | undefined;
|
||||
roomInstance: RoomInstance | null;
|
||||
|
||||
lastSeen: Date;
|
||||
|
||||
async updateStatusVisibility() {
|
||||
const PlayerStatusVisibilityEnum = z.nativeEnum(PlayerStatusVisibility);
|
||||
type PlayerStatusVisibilityEnum = z.infer<typeof PlayerStatusVisibilityEnum>;
|
||||
|
||||
const visibilityResult = PlayerStatusVisibilityEnum.safeParse(await this.#profile.getSetting(SettingKey.PlayerStatusVisibility));
|
||||
if (visibilityResult.success) this.statusVisibility = visibilityResult.data;
|
||||
}
|
||||
|
||||
async updateEnums() {
|
||||
if (!this.offline) await this.updateStatusVisibility();
|
||||
else this.statusVisibility = PlayerStatusVisibility.Offline;
|
||||
|
||||
// deviceClass
|
||||
const DeviceClassEnum = z.nativeEnum(DeviceClass);
|
||||
type DeviceClassEnum = z.infer<typeof DeviceClassEnum>;
|
||||
|
||||
const deviceClassResult = DeviceClassEnum.safeParse(await this.#profile.getKnownDeviceClass());
|
||||
if (deviceClassResult.success) this.deviceClass = deviceClassResult.data;
|
||||
|
||||
// vrMovementMode
|
||||
const VRMovementModeEnum = z.nativeEnum(VRMovementMode);
|
||||
type VRMovementModeEnum = z.infer<typeof VRMovementModeEnum>;
|
||||
|
||||
const vrMovementMoveResult = VRMovementModeEnum.safeParse(await this.#profile.getVRMovementMode());
|
||||
if (vrMovementMoveResult.success) this.vrMovementMode = vrMovementMoveResult.data;
|
||||
}
|
||||
|
||||
async export() {
|
||||
await this.updateEnums();
|
||||
const exp: PresenceExport = {
|
||||
playerId: this.playerId,
|
||||
roomInstance: this.roomInstance,
|
||||
statusVisibility: this.statusVisibility,
|
||||
deviceClass: this.deviceClass,
|
||||
vrMovementMode: this.vrMovementMode ? this.vrMovementMode : undefined
|
||||
}
|
||||
return Object.assign({}, exp); // hard copy/clone
|
||||
}
|
||||
|
||||
updateOffline() {
|
||||
if (Math.round(new Date().getTime() / 1000) - Math.round(this.lastSeen.getTime() / 1000) >= 60) this.offline = true;
|
||||
else this.offline = false;
|
||||
|
||||
this.lastSeen = new Date();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const presence: Set<PlayerPresence> = new Set();
|
||||
|
||||
class PresenceBase {
|
||||
|
||||
/**
|
||||
* Heavy. Use with caution.
|
||||
*/
|
||||
async getAllPresences() {
|
||||
const presSet: Set<PresenceExport> = new Set();
|
||||
for (const pres of presence.values()) presSet.add(await pres.export());
|
||||
return presSet;
|
||||
}
|
||||
|
||||
getAllRawPresences() {
|
||||
return presence;
|
||||
}
|
||||
|
||||
async create(player: Profile) {
|
||||
if (!presence.values().find(pres => pres.playerId == player.getId())) {
|
||||
const pres = new PlayerPresence(player);
|
||||
await pres.updateEnums();
|
||||
presence.add(pres);
|
||||
}
|
||||
log.d(`Presences: ${JSON.stringify(Array.from(await Presence.getAllPresences()))}`);
|
||||
}
|
||||
|
||||
async get(player: Profile) {
|
||||
const pres = presence.values().find(pres => pres.playerId == player.getId());
|
||||
if (pres) return pres;
|
||||
else {
|
||||
const pres = new PlayerPresence(player, true);
|
||||
await pres.updateEnums();
|
||||
presence.add(pres);
|
||||
return pres;
|
||||
}
|
||||
}
|
||||
|
||||
deleteDeadPresences() {
|
||||
for (const pres of presence.values())
|
||||
if (Math.round(new Date().getTime() / 1000) - Math.round(pres.lastSeen.getTime() / 1000) >= 60) {
|
||||
presence.delete(pres);
|
||||
clearInterval(pres.intervalId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const Presence = new PresenceBase();
|
||||
|
||||
const id = setInterval(() => {
|
||||
Presence.deleteDeadPresences();
|
||||
}, 480000); // delete dead presences every 8 minutes
|
||||
Deno.addSignalListener("SIGINT", async () => {
|
||||
clearInterval(id);
|
||||
const presArray = Presence.getAllRawPresences();
|
||||
for (const pres of presArray.values()) clearInterval(pres.intervalId);
|
||||
});
|
||||
|
||||
export default Presence;
|
||||
91
src/data/live/types.ts
Normal file
91
src/data/live/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { IntegratedRoomScene, RoomDetails } from "../content/roomtypes.ts";
|
||||
import Profile from "../profiles.ts";
|
||||
|
||||
export enum PhotonRegionCodeString {
|
||||
Europe = "eu",
|
||||
UnitedStates = "us",
|
||||
Asia = "asia",
|
||||
Japan = "jp",
|
||||
Australia = "au",
|
||||
UnitedStates_West = "usw",
|
||||
SouthAmerica = "sa",
|
||||
CanadaEast = "cae",
|
||||
SouthKorea = "kr",
|
||||
India = "@in",
|
||||
Russia = "ru",
|
||||
RussiaEast = "rue",
|
||||
None = "none"
|
||||
}
|
||||
|
||||
export enum PhotonRegionCodeNumber {
|
||||
eu,
|
||||
us,
|
||||
asia,
|
||||
jp,
|
||||
au = 5,
|
||||
usw,
|
||||
sa,
|
||||
cae,
|
||||
kr,
|
||||
"@in",
|
||||
ru,
|
||||
rue,
|
||||
none = 4
|
||||
}
|
||||
|
||||
export interface RoomInstance {
|
||||
|
||||
roomInstanceId: number,
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
location: IntegratedRoomScene,
|
||||
dataBlob?: string,
|
||||
eventId?: number,
|
||||
photonRegionId: PhotonRegionCodeString,
|
||||
photonRoomId: string,
|
||||
name?: string,
|
||||
maxCapacity: number,
|
||||
isFull: boolean,
|
||||
isPrivate: boolean,
|
||||
isInProgress: boolean
|
||||
|
||||
}
|
||||
|
||||
export interface InstanceOptions {
|
||||
|
||||
Room: RoomDetails,
|
||||
SceneIndex: number,
|
||||
EventId?: number,
|
||||
Name?: string,
|
||||
Private?: boolean,
|
||||
FirstPlayer?: Profile
|
||||
|
||||
}
|
||||
|
||||
export enum DeviceClass {
|
||||
Unknown,
|
||||
VR,
|
||||
Screen,
|
||||
Mobile,
|
||||
VRLow
|
||||
}
|
||||
|
||||
export enum PlayerStatusVisibility {
|
||||
Public,
|
||||
FriendsOnly,
|
||||
FavoriteFriendsOnly,
|
||||
Offline
|
||||
}
|
||||
|
||||
export enum VRMovementMode {
|
||||
Teleport,
|
||||
Walk
|
||||
}
|
||||
|
||||
export interface PlayerPresence {
|
||||
playerId: number;
|
||||
statusVisibility: PlayerStatusVisibility;
|
||||
deviceClass: DeviceClass;
|
||||
vrMovementMode: VRMovementMode;
|
||||
roomInstance: RoomInstance;
|
||||
}
|
||||
10
src/data/platformtypes.ts
Normal file
10
src/data/platformtypes.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum PlatformMask {
|
||||
None = 0,
|
||||
Steam = 1,
|
||||
Oculus = 2,
|
||||
PlayStation = 4,
|
||||
Microsoft = 8,
|
||||
HeadlessBot = 16,
|
||||
IOS = 32,
|
||||
All = -1
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { Config } from "../config.ts";
|
||||
import { AuthType } from "./users.ts";
|
||||
import * as JsonWebToken from "@gz/jwt";
|
||||
import { TokenBaseFormat } from "../apiutils.ts";
|
||||
import { RoomInstance } from "./live/instances.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";
|
||||
|
||||
const config = Config.getConfig();
|
||||
|
||||
@@ -98,73 +102,24 @@ class Profile {
|
||||
// surely this can be written better
|
||||
static getExportAccount(id: number): Promise<AccountExport | null> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.Username,
|
||||
),
|
||||
).then((val) => {
|
||||
Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Username)).then((val) => {
|
||||
if (val == null) resolve(null);
|
||||
else {
|
||||
const promises = {
|
||||
profileImage: Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.ProfileImage,
|
||||
),
|
||||
),
|
||||
isJunior: Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.Junior,
|
||||
),
|
||||
),
|
||||
platforms: Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.Platforms,
|
||||
),
|
||||
),
|
||||
displayName: Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.DisplayName,
|
||||
),
|
||||
),
|
||||
username: Redis.Database.get(
|
||||
Redis.buildKey(
|
||||
Redis.KeyGroups.Profiles.Root,
|
||||
id.toString(),
|
||||
Redis.KeyGroups.Profiles.Username,
|
||||
),
|
||||
),
|
||||
profileImage: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.ProfileImage)),
|
||||
isJunior: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Junior)),
|
||||
username: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.Username)),
|
||||
displayName: Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, id.toString(), Redis.KeyGroups.Profiles.DisplayName)),
|
||||
};
|
||||
|
||||
Promise.all(Object.values(promises)).then((values) => {
|
||||
resolve({
|
||||
accountId: id,
|
||||
profileImage: values[0] == null
|
||||
? "DefaultProfileImage.png"
|
||||
: values[0],
|
||||
isJunior: values[1] == null
|
||||
? false
|
||||
: JSON.parse(values[1]),
|
||||
platforms: values[2] == null
|
||||
? 1
|
||||
: JSON.parse(values[2]),
|
||||
displayName: values[3] == null
|
||||
? (values[4] == null
|
||||
? "DATABASEERROR"
|
||||
: values[4])
|
||||
: values[3],
|
||||
username: values[4] == null
|
||||
? "DATABASEERROR"
|
||||
: values[4],
|
||||
profileImage: values[0] == null ? "DefaultProfileImage.png" : values[0],
|
||||
isJunior: values[1] == null ? false : JSON.parse(values[1]),
|
||||
platforms: 1,
|
||||
username: values[2] == null ? "DATABASEERROR" : values[2],
|
||||
displayName: values[3] == null ? (values[2] == null ? "DATABASEERROR" : values[2]) : values[3],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -183,11 +138,13 @@ class Profile {
|
||||
|
||||
#instance: RoomInstance | null = null;
|
||||
|
||||
#socket: SignalRSocketHandler | null = null;
|
||||
|
||||
constructor(id: number) {
|
||||
this.#id = id;
|
||||
}
|
||||
|
||||
setInstance(instance: RoomInstance) {
|
||||
setInstance(instance: RoomInstance | null) {
|
||||
this.#instance = instance;
|
||||
}
|
||||
|
||||
@@ -207,18 +164,81 @@ 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);
|
||||
}
|
||||
|
||||
async getKnownDeviceClass() {
|
||||
const data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass));
|
||||
|
||||
const DeviceClassEnum = z.nativeEnum(DeviceClass);
|
||||
type DeviceClassEnum = z.infer<typeof DeviceClassEnum>
|
||||
|
||||
const result = DeviceClassEnum.safeParse(data);
|
||||
if (result.success) return result.data;
|
||||
else return DeviceClass.Unknown;
|
||||
}
|
||||
|
||||
async setVRMovementMode(movementMode: string | number) {
|
||||
return await this.setSetting(SettingKey.VRMovementMode, movementMode.toString());
|
||||
}
|
||||
|
||||
async getVRMovementMode() {
|
||||
const data = await this.getSetting(SettingKey.VRMovementMode);
|
||||
|
||||
const VRMovementModeEnum = z.nativeEnum(VRMovementMode);
|
||||
type VRMovementModeEnum = z.infer<typeof VRMovementModeEnum>
|
||||
|
||||
const result = VRMovementModeEnum.safeParse(data);
|
||||
if (result.success) return result.data;
|
||||
else return VRMovementMode.Teleport;
|
||||
}
|
||||
|
||||
setSocketHandler(handler: SignalRSocketHandler) {
|
||||
this.#socket = handler;
|
||||
}
|
||||
clearSocketHandler() {
|
||||
this.#socket = null;
|
||||
}
|
||||
getSocketHandler() {
|
||||
return this.#socket;
|
||||
}
|
||||
|
||||
// get, set instance
|
||||
// this.#instance: RoomInstance
|
||||
|
||||
async getToken() {
|
||||
const payload: ProfileTokenFormat = {
|
||||
iss: config.web.publichost,
|
||||
iss: `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`,
|
||||
sub: this.#id,
|
||||
role: (await this.getIsOperator()) ? 'developer' : 'user',
|
||||
exp: Math.round(Date.now() / 1000) + Math.round(config.auth.timeout * 60 * 60),
|
||||
typ: AuthType.Game
|
||||
};
|
||||
return await JsonWebToken.encode(payload, config.auth.secret, {algorithm: "HS512"});
|
||||
return await JsonWebToken.encode(payload, config.auth.secret, { algorithm: "HS512" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
src/data/profiletypes.ts
Normal file
4
src/data/profiletypes.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Setting {
|
||||
Key: string;
|
||||
Value: string;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export class User {
|
||||
|
||||
async getToken() {
|
||||
const payload: UserTokenFormat = {
|
||||
iss: config.web.publichost,
|
||||
iss: `${config.web.securepublichost ? 'https' : 'http'}://${config.web.publichost}`,
|
||||
sub: this.#client_id,
|
||||
exp: Math.round(Date.now() / 1000) + (config.auth.timeout * 60 * 60),
|
||||
typ: AuthType.Web
|
||||
|
||||
@@ -64,7 +64,9 @@ export const KeyGroups = {
|
||||
ProfileImage: "profileImage",
|
||||
Junior: "isJunior",
|
||||
Platforms: "platforms",
|
||||
DisplayName: "displayname",
|
||||
DisplayName: "displayName",
|
||||
Settings: "settings",
|
||||
DeviceClass: "deviceClass",
|
||||
},
|
||||
Operators: "operators",
|
||||
Users: {
|
||||
|
||||
85
src/main.ts
85
src/main.ts
@@ -1,11 +1,19 @@
|
||||
import * as Log from "@proxnet/undead-logging";
|
||||
import * as Config from "./config.ts";
|
||||
import { Database } from "./db.ts";
|
||||
import { APIUtils } from "./apiutils.ts";
|
||||
import { APIUtils, ProfileTokenSchema } from "./apiutils.ts";
|
||||
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 { IncomingMessage } from "../../AppData/Local/deno/npm/registry.npmjs.org/@types/connect/3.4.38/index.d.ts";
|
||||
import { decode } from "@gz/jwt";
|
||||
import Profile, { ProfileTokenFormat } from "./data/profiles.ts";
|
||||
import { SocketHandoff } from "./socket/handoff.ts";
|
||||
import internal from "node:stream";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { SignalRSocketHandler } from "./socket/socket.ts";
|
||||
|
||||
const instanceId = generateRandomString(64);
|
||||
|
||||
@@ -20,15 +28,11 @@ if (typeof config == "undefined") {
|
||||
Deno.exit(1);
|
||||
}
|
||||
if (config.auth.secret == Config.defaultConfig.auth.secret) {
|
||||
log.e(
|
||||
`Cannot start: Auth secret is default. Please change 'secrets.authSecret' in 'config.json'`,
|
||||
);
|
||||
log.e(`Cannot start: Auth secret is default. Please change 'secrets.authSecret' in 'config.json'`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
if (config.public.serverId == Config.defaultConfig.public.serverId) {
|
||||
log.e(
|
||||
`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`,
|
||||
);
|
||||
log.e(`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`);
|
||||
Deno.exit(1);
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ const authRouter = await import("./routes/auth.ts");
|
||||
const accountRouter = await import("./routes/account.ts");
|
||||
const imgRouter = await import("./routes/img.ts");
|
||||
const matchRouter = await import("./routes/match.ts");
|
||||
const notifyRouter = await import("./socket/route.ts");
|
||||
|
||||
app.use(nameserverRouter.route.path, nameserverRouter.route.router);
|
||||
app.use(apiRouter.route.path, apiRouter.route.router);
|
||||
@@ -85,21 +90,16 @@ app.use(authRouter.route.path, authRouter.route.router);
|
||||
app.use(accountRouter.route.path, accountRouter.route.router);
|
||||
app.use(imgRouter.route.path, imgRouter.route.router);
|
||||
app.use(matchRouter.route.path, matchRouter.route.router);
|
||||
app.use(notifyRouter.route.path, notifyRouter.route.router);
|
||||
|
||||
app.use((rq: express.Request, rs: express.Response) => {
|
||||
log.e(
|
||||
`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`,
|
||||
);
|
||||
log.e(`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
|
||||
rs.statusCode = 404;
|
||||
rs.json(
|
||||
APIUtils.genericResponseFormat(
|
||||
true,
|
||||
"Endpoint not found. Check your syntax and/or method.",
|
||||
),
|
||||
);
|
||||
rs.json(APIUtils.genericResponseFormat(true, "Endpoint not found. Check your syntax and/or method."));
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
const http = app.listen(config.web.port, config.web.host, () => {
|
||||
log.n(`Listening on http://${config.web.host}:${config.web.port}`);
|
||||
|
||||
@@ -112,6 +112,59 @@ try {
|
||||
http.close();
|
||||
});
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: http,
|
||||
path: "/notify/hub/v1"
|
||||
});
|
||||
wss.on('connection', (ws: WebSocket, rq: IncomingMessage, profile: Profile, connectionId: string) => {
|
||||
const handoff = SocketHandoff.find(connectionId);
|
||||
if (handoff) handoff.complete();
|
||||
log.d(typeof profile);
|
||||
new SignalRSocketHandler(ws, profile);
|
||||
});
|
||||
http.on('upgrade', async (rq: IncomingMessage, socket: internal.Duplex, head: Buffer) => {
|
||||
const errorHandler = (err: Error | undefined) => { log.e(`Socket error: ${err?.stack}`); };
|
||||
socket.on('error', errorHandler);
|
||||
|
||||
function writeUnauthorized() {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
let wrong = false;
|
||||
const unparsedToken = rq.headers['Authorization'];
|
||||
const connectionId = new URL(`http://${rq.headers.host ? rq.headers.host : 'localhost'}${rq.url}`).searchParams.get('connectionId');
|
||||
if (connectionId == null) wrong = true;
|
||||
else {
|
||||
if (typeof unparsedToken == 'string') {
|
||||
const splitToken = unparsedToken.split(' ')[1]
|
||||
if (splitToken) {
|
||||
try {
|
||||
|
||||
const decodedToken = await decode<ProfileTokenFormat>(splitToken, config.auth.secret, {algorithm: 'HS512'});
|
||||
const schemaResult = ProfileTokenSchema.safeParse(decodedToken);
|
||||
if (!schemaResult.success) wrong = true;
|
||||
else {
|
||||
wss.handleUpgrade(rq, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, rq, new Profile(decodedToken.sub), connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
} catch {
|
||||
wrong = true;
|
||||
}
|
||||
} else wrong = true;
|
||||
} else wrong = true;
|
||||
}
|
||||
|
||||
if (wrong) {
|
||||
writeUnauthorized();
|
||||
return;
|
||||
}
|
||||
socket.removeListener('error', errorHandler);
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
log.e(`Cannot start: Network could not be initalized. ${err}`);
|
||||
Deno.exit(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { APIUtils } from "../apiutils.ts";
|
||||
|
||||
export const route = APIUtils.createRouter("/api");
|
||||
@@ -8,3 +9,4 @@ 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);
|
||||
@@ -27,3 +27,19 @@ route.router.post('/v1/hile',
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
route.router.get('/v1/moderationBlockDetails',
|
||||
|
||||
APIUtils.Authentication,
|
||||
|
||||
(_rq, rs) => {
|
||||
// todo: moderation
|
||||
rs.json({
|
||||
ReportCategory: 0,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
Message: ""
|
||||
});
|
||||
}
|
||||
|
||||
);
|
||||
@@ -76,7 +76,6 @@ route.router.post("/token",
|
||||
|
||||
APIUtils.Authentication,
|
||||
express.urlencoded({ extended: true }),
|
||||
APIUtils.logBody,
|
||||
APIUtils.validateRequestBody<AuthBodyBase>(TokenRequestBodySchema),
|
||||
|
||||
async (
|
||||
@@ -93,9 +92,10 @@ route.router.post("/token",
|
||||
}
|
||||
|
||||
const conditionsMet = ![
|
||||
rq.body.client_id == "recroom",
|
||||
rq.body.platform == "0",
|
||||
rq.body.ver == '20191120',
|
||||
rq.body.client_id === "recroom",
|
||||
rq.body.platform === "0",
|
||||
rq.body.ver === '20191120',
|
||||
rq.body.device_class.length === 1,
|
||||
!(rq.body.device_id.length > 96),
|
||||
!(rq.body.client_secret.length > 96),
|
||||
!(rq.body.platform_id.length > 32),
|
||||
@@ -107,34 +107,12 @@ route.router.post("/token",
|
||||
requestFailed();
|
||||
return;
|
||||
}
|
||||
if (rq.body.grant_type == 'cached_login') {
|
||||
|
||||
const accounts = await rs.locals.user.getAssociatedProfiles();
|
||||
const targetAccount = parseInt(rq.body.account_id);
|
||||
let targetAccount: number;
|
||||
|
||||
if (isNaN(targetAccount)) {
|
||||
requestFailed();
|
||||
return;
|
||||
}
|
||||
if (!accounts.has(targetAccount)) {
|
||||
requestFailed("access_denied");
|
||||
return;
|
||||
}
|
||||
|
||||
rs.locals.user.addAssociatedDeviceId(rq.body.device_id);
|
||||
rs.locals.user.addAssociatedPlatformId(rq.body.platform_id);
|
||||
|
||||
const profile = new Profile(targetAccount);
|
||||
if (!(await Profile.exists(profile.getId()))) {
|
||||
requestFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await profile.getToken();
|
||||
rs.json({
|
||||
access_token: token,
|
||||
refresh_token: token,
|
||||
});
|
||||
} else {
|
||||
if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id);
|
||||
else {
|
||||
const refreshToken = rq.body.refresh_token;
|
||||
if (typeof refreshToken == 'undefined') {
|
||||
requestFailed();
|
||||
@@ -149,8 +127,9 @@ route.router.post("/token",
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = await rs.locals.user.getAssociatedProfiles();
|
||||
const targetAccount = parseInt(decodedToken.sub ? decodedToken.sub : "NaN");
|
||||
targetAccount = parseInt(decodedToken.sub ? decodedToken.sub : "NaN");
|
||||
|
||||
}
|
||||
|
||||
if (isNaN(targetAccount)) {
|
||||
requestFailed();
|
||||
@@ -175,6 +154,7 @@ route.router.post("/token",
|
||||
access_token: token,
|
||||
refresh_token: token,
|
||||
});
|
||||
}
|
||||
|
||||
await profile.setKnownDeviceClass(rq.body.device_class);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { APIUtils } from "../../apiutils.ts";
|
||||
import { APIUtils, NoBody } from "../../apiutils.ts";
|
||||
import express from "express";
|
||||
import Matchmaking from "../../data/live/base.ts";
|
||||
import Presence from "../../data/live/presence.ts";
|
||||
|
||||
export const route = APIUtils.createRouter('/player');
|
||||
|
||||
@@ -18,13 +20,10 @@ route.router.post('/login',
|
||||
express.urlencoded({extended: true}),
|
||||
APIUtils.validateRequestBody(LoginSchema),
|
||||
|
||||
(_rq, rs) => {
|
||||
// temporary
|
||||
(rq: express.Request<NoBody, NoBody, BaseLoginLock>, rs: express.Response) => {
|
||||
Matchmaking.createLoginLock(rs.locals.profile, rq.body.LoginLock);
|
||||
Presence.create(rs.locals.profile);
|
||||
rs.sendStatus(200);
|
||||
// check for existing login
|
||||
// set login lock
|
||||
// init presence
|
||||
// use device_class from token request
|
||||
},
|
||||
|
||||
)
|
||||
50
src/socket/handoff.ts
Normal file
50
src/socket/handoff.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const handoffs: Set<SocketHandoff> = new Set();
|
||||
|
||||
function randomId(length: number) {
|
||||
let result = '';
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let counter = 0;
|
||||
while (counter < length) {
|
||||
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||
counter += 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Lots of this is redundant. The WebSocket request already contains an access token for the profile, but I'd
|
||||
// like to make sure that connectionIds are freed automatically.
|
||||
export class SocketHandoff {
|
||||
|
||||
static generateId() {
|
||||
let id = randomId(48);
|
||||
while (handoffs.values().find(handoff => handoff.id == id)) id = randomId(48);
|
||||
return id;
|
||||
}
|
||||
static find(id: string) {
|
||||
return handoffs.values().find(handoff => handoff.id === id);
|
||||
}
|
||||
|
||||
id: string;
|
||||
|
||||
#timeout: number;
|
||||
|
||||
constructor() {
|
||||
this.id = SocketHandoff.generateId();
|
||||
|
||||
this.#timeout = setTimeout(() => {
|
||||
handoffs.delete(this);
|
||||
});
|
||||
|
||||
handoffs.add(this);
|
||||
}
|
||||
|
||||
delete() {
|
||||
clearTimeout(this.#timeout);
|
||||
handoffs.delete(this);
|
||||
}
|
||||
|
||||
complete() {
|
||||
this.delete();
|
||||
}
|
||||
|
||||
}
|
||||
18
src/socket/route.ts
Normal file
18
src/socket/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { APIUtils } from "../apiutils.ts";
|
||||
import { SocketHandoff } from "./handoff.ts";
|
||||
|
||||
export const route = APIUtils.createRouter('/notify');
|
||||
|
||||
route.router.post('/hub/v1/negotiate',
|
||||
|
||||
APIUtils.Authentication,
|
||||
|
||||
(_rq, rs) => {
|
||||
const handoff = new SocketHandoff();
|
||||
rs.json({
|
||||
connectionId: handoff.id,
|
||||
availableTransports: [{transport:"WebSockets",transferFormats:["Text"]}]
|
||||
});
|
||||
},
|
||||
|
||||
);
|
||||
32
src/socket/socket.ts
Normal file
32
src/socket/socket.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import WebSocket from "ws";
|
||||
import Profile from "../data/profiles.ts";
|
||||
import { IncomingMessage } from "node:http";
|
||||
import Logging from "@proxnet/undead-logging";
|
||||
|
||||
export class SignalRSocketHandler {
|
||||
|
||||
log: Logging = new Logging("SignalMock-");
|
||||
|
||||
#socket: WebSocket;
|
||||
#profile: Profile;
|
||||
|
||||
constructor(socket: WebSocket, player: Profile) {
|
||||
|
||||
this.#socket = socket;
|
||||
this.#profile = player;
|
||||
|
||||
player.setSocketHandler(this);
|
||||
this.log.source += player.getId().toString();
|
||||
|
||||
// log: we connected!!
|
||||
|
||||
Deno.addSignalListener('SIGINT', this.destroy);
|
||||
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.#socket.close();
|
||||
Deno.removeSignalListener('SIGINT', this.destroy);
|
||||
}
|
||||
|
||||
}
|
||||
22
src/socket/types.ts
Normal file
22
src/socket/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export enum MessageTypes {
|
||||
CancelInvocation,
|
||||
Close,
|
||||
Completion,
|
||||
Handshake,
|
||||
Invocation,
|
||||
Ping,
|
||||
StreamInvocation,
|
||||
StreamItem,
|
||||
Ack
|
||||
}
|
||||
|
||||
export interface SignalRMessage {
|
||||
arguments: object[],
|
||||
error?: string,
|
||||
invocationId?: string,
|
||||
item?: object,
|
||||
nonblocking: boolean,
|
||||
result?: object,
|
||||
target: string,
|
||||
type: MessageTypes
|
||||
}
|
||||
Reference in New Issue
Block a user