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

@@ -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;
}
#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) {
// todo: use room data to create room instance
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,9 +112,62 @@ 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);
}
Discord.login();
Discord.login();

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

@@ -26,4 +26,20 @@ route.router.post('/v1/hile',
log.w(`Client sent hile report: '${rq.body.Message}'`);
},
);
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);
if (isNaN(targetAccount)) {
requestFailed();
return;
}
if (!accounts.has(targetAccount)) {
requestFailed("access_denied");
return;
}
const accounts = await rs.locals.user.getAssociatedProfiles();
let targetAccount: number;
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,32 +127,34 @@ 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();
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,
});
}
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,
});
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
}