Further login process

* APIUtils addition: query validation
* Coach and Server accounts are now properly created if they do not exist
* Profiles now cannot be IDs 1 or 2 (reservedIds)
* Fixed profile username exists bug
* Added relationship manager
* Started relationship management
* DeviceClass and VRMovementMode enum defaults for reserved profiles
* Presence update simplification
* Progression fixes
* Relationship query and object fixes
* Base configuration is now rate limited
* Progression route no longer requires authentication, instead is rate limited
* Base relationships with reserved profiles (Coach and Server)
* DeviceClass required for login
* Get presence route
* Socket route no longer logs
* Socket target base finished
This commit is contained in:
2025-03-30 19:29:57 -04:00
parent 026f9c8bd8
commit 639e809a20
19 changed files with 270 additions and 81 deletions

View File

@@ -74,6 +74,15 @@ export const validateRequestBody = <T>(schema: z.ZodSchema<T>) => (rq: express.R
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors)); rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
} }
}; };
export const validateQuery = <T>(schema: z.ZodSchema<T>) => (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
try {
schema.parse(rq.query);
nxt();
} catch (error) {
if (error instanceof z.ZodError)
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
}
};
type genericResponse = { type genericResponse = {
failure: boolean, failure: boolean,

View File

@@ -6,7 +6,7 @@ import Logging from "@proxnet/undead-logging";
const log = new Logging("Presence"); const log = new Logging("Presence");
interface PresenceExport { export interface PresenceExport {
roomInstance: RoomInstance | null; roomInstance: RoomInstance | null;
playerId: number; playerId: number;
statusVisibility: PlayerStatusVisibility; statusVisibility: PlayerStatusVisibility;
@@ -14,7 +14,6 @@ interface PresenceExport {
vrMovementMode?: VRMovementMode; vrMovementMode?: VRMovementMode;
} }
// Hot mess
class PlayerPresence { class PlayerPresence {
intervalId: number; intervalId: number;
@@ -59,27 +58,21 @@ class PlayerPresence {
if (visibilityResult.success) this.statusVisibility = visibilityResult.data; if (visibilityResult.success) this.statusVisibility = visibilityResult.data;
} }
async updateEnums() { async update() {
this.updateOffline();
if (!this.offline) await this.updateStatusVisibility(); if (!this.offline) await this.updateStatusVisibility();
else this.statusVisibility = PlayerStatusVisibility.Offline; else this.statusVisibility = PlayerStatusVisibility.Offline;
// deviceClass this.deviceClass = await this.#profile.getKnownDeviceClass();
const DeviceClassEnum = z.nativeEnum(DeviceClass);
type DeviceClassEnum = z.infer<typeof DeviceClassEnum>;
const deviceClassResult = DeviceClassEnum.safeParse(await this.#profile.getKnownDeviceClass()); this.vrMovementMode = await this.#profile.getVRMovementMode();
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;
} }
/**
* Export presence object. Please make sure to update values with `Presence.update()` (async) before calling this.
*/
async export() { async export() {
await this.updateEnums(); await this.update();
const exp: PresenceExport = { const exp: PresenceExport = {
playerId: this.playerId, playerId: this.playerId,
roomInstance: this.roomInstance, roomInstance: this.roomInstance,
@@ -108,7 +101,10 @@ class PresenceBase {
*/ */
async getAllPresences() { async getAllPresences() {
const presSet: Set<PresenceExport> = new Set(); const presSet: Set<PresenceExport> = new Set();
for (const pres of presence.values()) presSet.add(await pres.export()); for (const pres of presence.values()) {
await pres.update();
presSet.add(await pres.export());
}
return presSet; return presSet;
} }
@@ -119,7 +115,7 @@ class PresenceBase {
async create(player: Profile) { async create(player: Profile) {
if (!presence.values().find(pres => pres.playerId == player.getId())) { if (!presence.values().find(pres => pres.playerId == player.getId())) {
const pres = new PlayerPresence(player); const pres = new PlayerPresence(player);
await pres.updateEnums(); await pres.update();
presence.add(pres); presence.add(pres);
} }
log.d(`Presences: ${JSON.stringify(Array.from(await Presence.getAllPresences()))}`); log.d(`Presences: ${JSON.stringify(Array.from(await Presence.getAllPresences()))}`);
@@ -130,7 +126,7 @@ class PresenceBase {
if (pres) return pres; if (pres) return pres;
else { else {
const pres = new PlayerPresence(player, true); const pres = new PlayerPresence(player, true);
await pres.updateEnums(); await pres.update();
presence.add(pres); presence.add(pres);
return pres; return pres;
} }

View File

@@ -57,7 +57,7 @@ export class ProfileProgressionManager extends ProfileContentManager {
if (xp >= item.RequiredXp) { if (xp >= item.RequiredXp) {
const current = config?.LevelProgressionMaps[config?.LevelProgressionMaps.indexOf(item)]; const current = config?.LevelProgressionMaps[config?.LevelProgressionMaps.indexOf(item)];
if (typeof current == 'undefined') return null; if (typeof current == 'undefined') return 1;
else return current.Level; else return current.Level;
} }
@@ -72,7 +72,9 @@ export class ProfileProgressionManager extends ProfileContentManager {
const parsedData = parseInt(data); const parsedData = parseInt(data);
if (isNaN(parsedData)) { if (isNaN(parsedData)) {
log.w(`Parsed xp data for ${this.profileId} is NaN!`); log.w(`Parsed xp data for ${this.profileId} is NaN!`);
return 0; // fallback since progression data is required const one = config?.LevelProgressionMaps[1];
if (typeof one == 'undefined' && !one) return 0; // fallback since progression data is required
else return one.RequiredXp;
} else return parsedData; } else return parsedData;
} }

View File

@@ -0,0 +1,64 @@
import { Profile } from "../profiles.ts";
import { ProfileContentManager } from "./base/profilemanagerbase.ts";
enum RelationshipType {
None,
FriendRequestSent,
FriendRequestReceived,
Friend
}
enum ReciprocalStatus {
None,
Local,
Remote,
Mutual
}
interface Relationship {
PlayerID: number, // target player, not this player
RelationshipType: RelationshipType,
Muted: ReciprocalStatus,
Ignored: ReciprocalStatus
}
export class ProfileRelationshipManager extends ProfileContentManager {
#baseRelationships: Relationship[] = [
{
PlayerID: 1,
RelationshipType: RelationshipType.Friend,
Muted: ReciprocalStatus.None,
Ignored: ReciprocalStatus.None
},
{
PlayerID: 2,
RelationshipType: RelationshipType.Friend,
Muted: ReciprocalStatus.None,
Ignored: ReciprocalStatus.None
}
]
getRelationships() {
return this.#baseRelationships; // temporary
}
setPlayerIgnored(player: Profile) {
}
setPlayerMuted(player: Profile) {
}
getPlayerRelationship(player: Profile) {
}
sendPlayerFriendRequest(player: Profile) {
}
}

View File

@@ -5,9 +5,10 @@ export class ProfileReputationManager extends ProfileContentManager {
async getReputation() { // async temporary async getReputation() { // async temporary
return { return {
AccountId: this.profileId, AccountId: this.profileId,
Noterity: 0.0, Noteriety: 0.0,
CheerGeneral: 0, CheerGeneral: 0,
CheerHelpful: 0, CheerHelpful: 0,
CheerGreatHost: 0,
CheerSportsman: 0, CheerSportsman: 0,
CheerCreative: 0, CheerCreative: 0,
CheerCredit: 0, CheerCredit: 0,

View File

@@ -12,6 +12,7 @@ import { ProfileSettingsManager } from "./profile/settings.ts";
import { ProfileProgressionManager } from "./profile/progression.ts"; import { ProfileProgressionManager } from "./profile/progression.ts";
import { ProfileReputationManager } from "./profile/reputation.ts"; import { ProfileReputationManager } from "./profile/reputation.ts";
import Logging from "@proxnet/undead-logging"; import Logging from "@proxnet/undead-logging";
import { ProfileRelationshipManager } from "./profile/relationships.ts";
const config = Config.getConfig(); const config = Config.getConfig();
@@ -19,11 +20,17 @@ const log = new Logging("Profiles");
interface ProfileInitOptions { interface ProfileInitOptions {
username: string; username: string;
/**
* Ignore the random generation of profile IDs; use this ID.
*
* Make sure the target account does not exist before using this option.
*/
id?: number;
} }
interface AccountExport { interface AccountExport {
accountId: number; accountId: number;
profileImage: string; profileImage: string;
isJunior: boolean; isJunior?: boolean;
platforms: number; platforms: number;
username: string; username: string;
displayName: string; displayName: string;
@@ -34,6 +41,8 @@ export interface ProfileTokenFormat extends TokenBaseFormat {
typ: AuthType.Game; typ: AuthType.Game;
} }
const reservedIds = [1, 2];
class Profile { class Profile {
static async exists(id: number) { static async exists(id: number) {
return (await Redis.Database.exists( return (await Redis.Database.exists(
@@ -44,6 +53,11 @@ class Profile {
), ),
)) >= 1; )) >= 1;
} }
static async existsByName(name: string) {
return (await Redis.Database.exists(
Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name),
)) >= 1;
}
static async getUniqueId() { static async getUniqueId() {
let id = Math.round(Math.random() * Math.pow(2, 31)); let id = Math.round(Math.random() * Math.pow(2, 31));
@@ -55,18 +69,15 @@ class Profile {
Redis.KeyGroups.Profiles.Username, Redis.KeyGroups.Profiles.Username,
), ),
)) >= 1 )) >= 1
) { || reservedIds.includes(id)
id = await this.getUniqueId(); ) id = await this.getUniqueId();
}
return id; return id;
} }
static async byName(name: string) { static async byName(name: string) {
const id = await Redis.Database.get( const id = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name));
Redis.buildKey(Redis.KeyGroups.Profile_Usernames, name),
);
if (id == null) return null; if (id == null) return null;
else return new Profile(parseInt(id, 10)); else return new Profile(parseInt(id));
} }
static async getUniqueUsername() { static async getUniqueUsername() {
@@ -83,12 +94,11 @@ class Profile {
static async init(options?: ProfileInitOptions) { static async init(options?: ProfileInitOptions) {
const optionsSpecified = typeof options !== "undefined"; const optionsSpecified = typeof options !== "undefined";
if (options?.username) { if (options?.username && await Profile.existsByName(options.username)) return null;
const existingUser = await Profile.byName(options.username);
if (existingUser == null) return null;
}
const newId = await this.getUniqueId(); let newId: number;
if (options?.id) newId = options.id;
else newId = await this.getUniqueId();
const newUsername = optionsSpecified const newUsername = optionsSpecified
? options.username ? options.username
: await this.getUniqueUsername(); : await this.getUniqueUsername();
@@ -153,6 +163,7 @@ class Profile {
Settings = new ProfileSettingsManager(); Settings = new ProfileSettingsManager();
Progression = new ProfileProgressionManager(); Progression = new ProfileProgressionManager();
Reputation = new ProfileReputationManager(); Reputation = new ProfileReputationManager();
Relationships = new ProfileRelationshipManager();
constructor(id: number) { constructor(id: number) {
this.#id = id; this.#id = id;
@@ -161,6 +172,7 @@ class Profile {
this.Settings.setProfile(this.#id); this.Settings.setProfile(this.#id);
this.Progression.setProfile(this.#id); this.Progression.setProfile(this.#id);
this.Reputation.setProfile(this.#id); this.Reputation.setProfile(this.#id);
this.Relationships.setProfile(this.#id);
} }
setInstance(instance: RoomInstance | null) { setInstance(instance: RoomInstance | null) {
@@ -183,11 +195,13 @@ class Profile {
return await Profile.getExportAccount(this.#id); return await Profile.getExportAccount(this.#id);
} }
async setKnownDeviceClass(deviceClass: string | number) { async setKnownDeviceClass(deviceClass: DeviceClass) {
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass), deviceClass); await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass), deviceClass);
} }
async getKnownDeviceClass() { async getKnownDeviceClass() {
if (reservedIds.includes(this.#id)) return DeviceClass.Unknown;
const data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass)); const data = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DeviceClass));
if (data == null) { if (data == null) {
log.w(`No known device class for ${this.#id}`); log.w(`No known device class for ${this.#id}`);
@@ -212,14 +226,16 @@ class Profile {
} }
async getVRMovementMode() { async getVRMovementMode() {
if (reservedIds.includes(this.#id)) return VRMovementMode.Teleport;
const data = await this.Settings.getSetting(SettingKey.VRMovementMode); const data = await this.Settings.getSetting(SettingKey.VRMovementMode);
if (data == null) { if (data == null) {
log.w(`No known device class for ${this.#id}`); log.w(`No known VR movement mode for ${this.#id} (harmless if OOBE not ran)`);
return VRMovementMode.Teleport; return VRMovementMode.Teleport;
} }
const parsedData = parseInt(data); const parsedData = parseInt(data);
if (isNaN(parsedData)) { if (isNaN(parsedData)) {
log.w(`Malformed device class for ${this.#id}`); log.w(`Malformed VR movement mode for ${this.#id}`);
return VRMovementMode.Teleport; return VRMovementMode.Teleport;
} }
@@ -258,6 +274,7 @@ class Profile {
const profiles: Map<number, Profile> = new Map() const profiles: Map<number, Profile> = new Map()
// Control what is available to references
class UnifiedProfileBase { class UnifiedProfileBase {
get(id: number) { get(id: number) {
@@ -278,6 +295,10 @@ class UnifiedProfileBase {
return await Profile.exists(id); return await Profile.exists(id);
} }
async existsByName(name: string) {
return await Profile.existsByName(name);
}
} }
const UnifiedProfile = new UnifiedProfileBase(); const UnifiedProfile = new UnifiedProfileBase();

View File

@@ -188,8 +188,8 @@ try {
PLACE TEST HERE PLACE TEST HERE
*/ */
if (!(await UnifiedProfile.exists(1))) UnifiedProfile.create({ username: "Coach" }); // create Coach if they do not exist if (!(await UnifiedProfile.existsByName("Coach"))) UnifiedProfile.create({ username: "Coach", id: 1 }); // create Coach id 1 if they do not exist
if (!(await UnifiedProfile.exists(2))) UnifiedProfile.create({ username: "Server" }); // create Server if they do not exist if (!(await UnifiedProfile.existsByName("Server"))) UnifiedProfile.create({ username: "Server", id: 2 }); // create Server id 2 if they do not exist
}); });

View File

@@ -28,6 +28,12 @@ route.router.post("/create",
async (_rq, rs) => { async (_rq, rs) => {
const newAcc = await Profile.init(); const newAcc = await Profile.init();
if (newAcc == null) {
rs.json({
success: false
});
return;
}
rs.locals.user.addAssociatedProfile(newAcc.getId()); rs.locals.user.addAssociatedProfile(newAcc.getId());

View File

@@ -3,7 +3,9 @@ import { GameConfigs } from "../../data/config.ts";
export const route = APIUtils.createRouter("/config"); export const route = APIUtils.createRouter("/config");
route.router.get("/v2", (_rq, rs) => { const rateLimit = new APIUtils.RateLimiter(60, 2);
route.router.get("/v2", rateLimit.middle(), (_rq, rs) => {
const config = GameConfigs.getConfig(); const config = GameConfigs.getConfig();
if (config == null) rs.sendStatus(500); if (config == null) rs.sendStatus(500);
else rs.json(config); else rs.json(config);
@@ -11,6 +13,7 @@ route.router.get("/v2", (_rq, rs) => {
route.router.get('/v1/amplitude', route.router.get('/v1/amplitude',
APIUtils.setCacheAllowed, APIUtils.setCacheAllowed,
rateLimit.middle(),
(_rq, rs) => { (_rq, rs) => {
rs.json({AmplitudeKey: ""}); rs.json({AmplitudeKey: ""});
} }

View File

@@ -10,7 +10,7 @@ route.router.get('/v1/:id',
APIUtils.Authentication, APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Game), APIUtils.AuthenticationType(AuthType.Game),
(rq: express.Request<{ id: string }>, rs) => { async (rq: express.Request<{ id: string }>, rs) => {
const unparsedPlayerId = rq.params.id; const unparsedPlayerId = rq.params.id;
const parsedPlayerId = parseInt(unparsedPlayerId); const parsedPlayerId = parseInt(unparsedPlayerId);
if (isNaN(parsedPlayerId)) { if (isNaN(parsedPlayerId)) {
@@ -18,7 +18,7 @@ route.router.get('/v1/:id',
return; return;
} }
rs.json(UnifiedProfile.get(parsedPlayerId).Reputation.getReputation()); rs.json(await UnifiedProfile.get(parsedPlayerId).Reputation.getReputation());
} }
); );

View File

@@ -5,11 +5,13 @@ import UnifiedProfile from "../../data/profiles.ts";
const log = new Logging("ProgressionRoute"); const log = new Logging("ProgressionRoute");
const rateLimit = new APIUtils.RateLimiter(60, 2);
export const route = APIUtils.createRouter("/players"); export const route = APIUtils.createRouter("/players");
route.router.get('/v1/progression/:id', route.router.get('/v1/progression/:id',
APIUtils.Authentication, rateLimit.middle(),
async (rq: express.Request<{ id: string }>, rs) => { async (rq: express.Request<{ id: string }>, rs) => {
const unparsedPlayerId = rq.params.id; const unparsedPlayerId = rq.params.id;

View File

@@ -9,7 +9,7 @@ route.router.get('/v2/get',
APIUtils.AuthenticationType(AuthType.Game), APIUtils.AuthenticationType(AuthType.Game),
(_rq, rs) => { (_rq, rs) => {
rs.json([]); // temporary rs.json(rs.locals.profile.Relationships.getRelationships());
} }
); );

View File

@@ -98,6 +98,7 @@ route.router.post("/token",
rq.body.platform === "0", rq.body.platform === "0",
rq.body.ver === '20191120', rq.body.ver === '20191120',
rq.body.device_class.length === 1, rq.body.device_class.length === 1,
!isNaN(Number(rq.body.device_class)),
!(rq.body.device_id.length > 96), !(rq.body.device_id.length > 96),
!(rq.body.client_secret.length > 96), !(rq.body.client_secret.length > 96),
!(rq.body.platform_id.length > 32), !(rq.body.platform_id.length > 32),
@@ -110,7 +111,6 @@ route.router.post("/token",
return; return;
} }
const accounts = await rs.locals.user.getAssociatedProfiles();
let targetAccount: number; let targetAccount: number;
if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id); if (rq.body.grant_type == 'cached_login') targetAccount = parseInt(rq.body.account_id);
@@ -137,6 +137,8 @@ route.router.post("/token",
requestFailed(); requestFailed();
return; return;
} }
const accounts = await rs.locals.user.getAssociatedProfiles();
if (!accounts.has(targetAccount)) { if (!accounts.has(targetAccount)) {
requestFailed("access_denied"); requestFailed("access_denied");
return; return;
@@ -160,6 +162,6 @@ route.router.post("/token",
refresh_token: token, refresh_token: token,
}); });
await profile.setKnownDeviceClass(rq.body.device_class); await profile.setKnownDeviceClass(Number(rq.body.device_class));
}, },
); );

View File

@@ -2,9 +2,10 @@ import { z } from "zod";
import { APIUtils, NoBody } from "../../apiutils.ts"; import { APIUtils, NoBody } from "../../apiutils.ts";
import express from "express"; import express from "express";
import Matchmaking from "../../data/live/base.ts"; import Matchmaking from "../../data/live/base.ts";
import Presence from "../../data/live/presence.ts"; import Presence, { PresenceExport } from "../../data/live/presence.ts";
import { AuthType } from "../../data/users.ts"; import { AuthType } from "../../data/users.ts";
import Logging from "@proxnet/undead-logging"; import Logging from "@proxnet/undead-logging";
import UnifiedProfile from "../../data/profiles.ts";
const log = new Logging("MatchPlayerRoute"); const log = new Logging("MatchPlayerRoute");
@@ -18,6 +19,31 @@ const LoginSchema = z.object({
LoginLock: z.string().uuid("LoginLock must be a UUIDv4") LoginLock: z.string().uuid("LoginLock must be a UUIDv4")
}); });
route.router.get('/',
APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Game),
APIUtils.validateQuery(z.object({ id: z.union([z.string(), z.array(z.string())]) })),
async (rq: express.Request<NoBody, PresenceExport[], NoBody, { id: string[] | string }>, rs) => {
let ids: number[] = [];
if (typeof rq.query.id == 'object') ids = rq.query.id.map(val => parseInt(val));
else ids.push(parseInt(rq.query.id));
ids = ids.filter(val => !isNaN(val));
const presExport: PresenceExport[] = [];
for (const id of ids) {
const pres = await Presence.get(UnifiedProfile.get(id));
await pres.update();
presExport.push(await pres.export());
}
rs.json(presExport);
log.d(JSON.stringify(presExport));
}
)
route.router.post('/login', route.router.post('/login',
APIUtils.Authentication, APIUtils.Authentication,
@@ -40,8 +66,9 @@ route.router.post('/logout',
express.urlencoded({extended: true}), express.urlencoded({extended: true}),
APIUtils.validateRequestBody(LoginSchema), APIUtils.validateRequestBody(LoginSchema),
(rq, rs) => { (_rq, rs) => {
Matchmaking.deleteLoginLock(rs.locals.profile); Matchmaking.deleteLoginLock(rs.locals.profile);
rs.sendStatus(200);
} }
) )
@@ -56,6 +83,7 @@ route.router.post('/heartbeat',
async (_rq, rs) => { async (_rq, rs) => {
const pres = await Presence.get(rs.locals.profile); const pres = await Presence.get(rs.locals.profile);
await pres.update();
log.d(`pres heartbeat for ${rs.locals.profile.getId()}: ${JSON.stringify(await pres.export())}`); log.d(`pres heartbeat for ${rs.locals.profile.getId()}: ${JSON.stringify(await pres.export())}`);
rs.json(await pres.export()); rs.json(await pres.export());
} }

View File

@@ -2,7 +2,6 @@ import { APIUtils } from "../apiutils.ts";
import { Config } from "../config.ts"; import { Config } from "../config.ts";
import { AuthType } from "../data/users.ts"; import { AuthType } from "../data/users.ts";
import { SocketHandoff } from "./handoff.ts"; import { SocketHandoff } from "./handoff.ts";
import express from "express";
const config = Config.getConfig(); const config = Config.getConfig();
@@ -12,8 +11,6 @@ route.router.post('/hub/v1/negotiate',
APIUtils.Authentication, APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Game), APIUtils.AuthenticationType(AuthType.Game),
express.urlencoded({ extended: true }),
APIUtils.logBody,
(_rq, rs) => { (_rq, rs) => {
const handoff = new SocketHandoff(); const handoff = new SocketHandoff();

View File

@@ -1,6 +1,18 @@
import { Profile } from "../data/profiles.ts"; import { Profile } from "../data/profiles.ts";
import Logging from "@proxnet/undead-logging"; import Logging from "@proxnet/undead-logging";
import { Message, MessageKind, SignalMessageType, SignalRMessage, SignalRMessageSchema, TargetResult, TargetResultFailure, TargetResultSuccess, TargetResultType } from "./types.ts"; import {
CompletionMessage,
Message,
MessageKind,
SignalMessageType,
SignalRMessage,
SignalRMessageSchema,
TargetResult,
TargetResultFailure,
TargetResultNotATarget,
TargetResultSuccess,
TargetResultType
} from "./types.ts";
import { SocketTarget } from "./targets/targetbase.ts"; import { SocketTarget } from "./targets/targetbase.ts";
import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts";
@@ -26,22 +38,55 @@ export class SignalRSocketHandler {
} }
async #dispatchTarget<T = unknown>(target: string, args: object[]): Promise<TargetResult> { async #dispatchTarget<T = unknown>(target: string, args: unknown): Promise<TargetResult> {
const targetExec = this.#Targets.get(target); const targetExec = this.#Targets.get(target);
if (!targetExec) return { type: TargetResultType.Failure } as TargetResultFailure; if (!targetExec) return { type: TargetResultType.NotATarget } as TargetResultNotATarget;
else return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess<T>; else {
try {
return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess<T>;
} catch (err) {
this.#log.w(`Target '${target}' function error: ${err}`);
if (err instanceof Error) return { type: TargetResultType.Failure, err: err } as TargetResultFailure;
else return { type: TargetResultType.Failure, err: `${err}` } as TargetResultFailure;
}
}
} }
#onMessage(message: Message) { async #onMessage(message: Message) {
if (message.kind == MessageKind.Protocol) { if (message.kind == MessageKind.Protocol) {
this.sendRaw({}); this.sendRaw({});
return; return;
} else { } else {
this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`); this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`);
if (message.data.type == SignalMessageType.Invocation && message.data.invocationId) { // don't send completion messages for nonblocking invocations
const res = await this.#dispatchTarget(message.data.target, message.data.arguments[0]); // rec room only uses the first index
if (res.type == TargetResultType.Success) {
const signalRes: CompletionMessage = {
type: SignalMessageType.Completion,
invocationId: message.data.invocationId,
result: JSON.stringify(res.data)
}
this.sendRaw(signalRes);
} else if (res.type == TargetResultType.Failure) {
const signalRes: CompletionMessage = {
type: SignalMessageType.Completion,
invocationId: message.data.invocationId,
error: res.err instanceof Error ? res.err.message : res.err
}
this.sendRaw(signalRes);
} else {
const signalRes: CompletionMessage = {
type: SignalMessageType.Completion,
invocationId: message.data.invocationId,
error: "Target not found"
}
this.sendRaw(signalRes);
}
}
} }
} }
async #init() { #init() {
this.#log.source += this.#profile.getId().toString(); this.#log.source += this.#profile.getId().toString();
this.#log.i(`Created hub socket`); this.#log.i(`Created hub socket`);
@@ -82,6 +127,9 @@ export class SignalRSocketHandler {
sendRaw(data: object) { sendRaw(data: object) {
this.#socket.send(`${JSON.stringify(data)}\u001e`); this.#socket.send(`${JSON.stringify(data)}\u001e`);
// todo sometime: make this less confusing
const type = `Type: ${JSON.stringify(data) == '{}' ? 'Protocol Message' : `${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`}`;
this.#log.d(`SERVER MESSAGE\n ${type}\n ${JSON.stringify(data as SignalRMessage)}`);
} }
} }

View File

@@ -1,5 +1,10 @@
import { z } from "zod";
import { SocketTarget } from "./targetbase.ts"; import { SocketTarget } from "./targetbase.ts";
const ArgumentSchema = z.object({
PlayerIds: z.array(z.number())
});
export class PlayerSocketSubscriptionTarget extends SocketTarget { export class PlayerSocketSubscriptionTarget extends SocketTarget {
subscriptions: number[] = []; subscriptions: number[] = [];
@@ -9,8 +14,12 @@ export class PlayerSocketSubscriptionTarget extends SocketTarget {
} }
// deno-lint-ignore require-await // deno-lint-ignore require-await
override async exec(_args: (object | string | number | boolean)[]) { override async exec(args: unknown) {
return; const parsed = ArgumentSchema.safeParse(args);
if (parsed.success) {
this.setSubscriptions(parsed.data.PlayerIds);
return;
} else throw new Error("Invalid arguments");
} }
} }

View File

@@ -13,7 +13,7 @@ export class SocketTarget {
} }
// deno-lint-ignore require-await // deno-lint-ignore require-await
async exec(_args: (object | string | number | boolean)[]) { async exec(_args: unknown) {
throw new Error("Execution for this target is not set."); throw new Error("Execution for this target is not set.");
} }

View File

@@ -4,14 +4,14 @@ export enum MessageKind {
Protocol, Protocol,
Data Data
} }
interface MessageBase { export interface MessageBase {
kind: MessageKind kind: MessageKind
} }
interface DataMessage extends MessageBase { export interface DataMessage extends MessageBase {
kind: MessageKind.Data, kind: MessageKind.Data,
data: SignalRMessage data: SignalRMessage
} }
interface ProtocolMessage extends MessageBase { export interface ProtocolMessage extends MessageBase {
kind: MessageKind.Protocol kind: MessageKind.Protocol
} }
export type Message = ProtocolMessage | DataMessage; export type Message = ProtocolMessage | DataMessage;
@@ -34,68 +34,68 @@ export enum SignalMessageType {
Close Close
} }
interface BaseMessage { export interface BaseMessage {
type: SignalMessageType; type: SignalMessageType;
} }
interface InvocationMessage extends BaseMessage { export interface InvocationMessage extends BaseMessage {
type: SignalMessageType.Invocation; type: SignalMessageType.Invocation;
target: string; target: string;
arguments: unknown[]; arguments: unknown[];
invocationId?: string; invocationId?: string;
} }
interface StreamItemMessage extends BaseMessage { export interface StreamItemMessage extends BaseMessage {
type: SignalMessageType.StreamItem; type: SignalMessageType.StreamItem;
invocationId: string; invocationId: string;
item: unknown; item: unknown;
} }
interface CompletionMessage extends BaseMessage { export interface CompletionMessage extends BaseMessage {
type: SignalMessageType.Completion; type: SignalMessageType.Completion;
invocationId: string; invocationId: string;
result?: unknown; result?: unknown;
error?: string; error?: string;
} }
interface PingMessage extends BaseMessage { export interface PingMessage extends BaseMessage {
type: SignalMessageType.Ping; type: SignalMessageType.Ping;
} }
interface CloseMessage extends BaseMessage { export interface CloseMessage extends BaseMessage {
type: SignalMessageType.Close; type: SignalMessageType.Close;
error?: string; error?: string;
} }
const BaseMessageSchema = z.object({ export const BaseMessageSchema = z.object({
type: z.nativeEnum(SignalMessageType), type: z.nativeEnum(SignalMessageType),
}); });
const InvocationMessageSchema = BaseMessageSchema.extend({ export const InvocationMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Invocation), type: z.literal(SignalMessageType.Invocation),
target: z.string(), target: z.string(),
arguments: z.array(z.unknown()), arguments: z.array(z.unknown()),
invocationId: z.string().optional(), invocationId: z.string().optional(),
}); });
const StreamItemMessageSchema = BaseMessageSchema.extend({ export const StreamItemMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.StreamItem), type: z.literal(SignalMessageType.StreamItem),
invocationId: z.string(), invocationId: z.string(),
item: z.unknown(), item: z.unknown(),
}); });
const CompletionMessageSchema = BaseMessageSchema.extend({ export const CompletionMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Completion), type: z.literal(SignalMessageType.Completion),
invocationId: z.string(), invocationId: z.string(),
result: z.unknown().optional(), result: z.unknown().optional(),
error: z.string().optional(), error: z.string().optional(),
}); });
const PingMessageSchema = BaseMessageSchema.extend({ export const PingMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Ping), type: z.literal(SignalMessageType.Ping),
}); });
const CloseMessageSchema = BaseMessageSchema.extend({ export const CloseMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Close), type: z.literal(SignalMessageType.Close),
error: z.string().optional(), error: z.string().optional(),
}); });
@@ -113,7 +113,7 @@ export enum TargetResultType {
Failure, Failure,
NotATarget NotATarget
} }
interface TargetResultBase { export interface TargetResultBase {
type: TargetResultType type: TargetResultType
} }
export interface TargetResultSuccess<T = unknown> extends TargetResultBase { export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
@@ -122,6 +122,7 @@ export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
} }
export interface TargetResultFailure extends TargetResultBase { export interface TargetResultFailure extends TargetResultBase {
type: TargetResultType.Failure type: TargetResultType.Failure
err: string | Error
} }
export interface TargetResultNotATarget extends TargetResultBase { export interface TargetResultNotATarget extends TargetResultBase {
type: TargetResultType.NotATarget type: TargetResultType.NotATarget