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:
2025-03-27 00:44:58 -04:00
parent 3538321487
commit c920dbe88a
23 changed files with 792 additions and 194 deletions

View File

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

@@ -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"
]
}

View File

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

View File

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

View File

@@ -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[],

View File

@@ -0,0 +1,9 @@
export enum SettingKey {
PlayerStatusVisibility = "PlayerStatusVisibility",
VRMovementMode = "VR_MOVEMENT_MODE",
}
export enum SettingDefault {
PlayerStatusVisibility = "1",
VRMovementMode = "0"
}

View File

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

View File

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

@@ -0,0 +1,10 @@
export enum PlatformMask {
None = 0,
Steam = 1,
Oculus = 2,
PlayStation = 4,
Microsoft = 8,
HeadlessBot = 16,
IOS = 32,
All = -1
}

View File

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

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

View File

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

View File

@@ -64,7 +64,9 @@ export const KeyGroups = {
ProfileImage: "profileImage",
Junior: "isJunior",
Platforms: "platforms",
DisplayName: "displayname",
DisplayName: "displayName",
Settings: "settings",
DeviceClass: "deviceClass",
},
Operators: "operators",
Users: {

View File

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

View File

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

View File

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

View File

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

View File

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