From 9e4bfc83684e76bc25d803e4eb92345639d4d1b7 Mon Sep 17 00:00:00 2001 From: zombieb Date: Sun, 4 May 2025 02:25:09 -0400 Subject: [PATCH] my power's back * Steam authentication * Profile events * Objective fixes for some rooms (reccenter, goldentrophy, etc) * Profile meta (displayname) somewhat works * Player socket subscriptions --- CONFIG.md | 27 ++++-- README.md | 10 +- src/apiutils.ts | 2 +- src/config.ts | 2 + src/data/baseevent.ts | 65 +++++++++++++ src/data/config.ts | 2 +- src/data/content/data.ts | 94 +++++++++++++++++++ src/data/live/instances.ts | 4 + src/data/profile/reputation.ts | 1 + .../{content/images.ts => profileevents.ts} | 30 ++---- src/data/profiles.ts | 34 ++++++- src/data/steam.ts | 68 ++++++++++++++ src/data/users.ts | 1 + src/db.ts | 3 +- src/main.ts | 19 ++-- src/routes/account/account.ts | 17 +++- src/routes/auth/account.ts | 2 +- src/routes/auth/connect.ts | 22 +++++ src/routes/img.ts | 9 +- src/routes/user.ts | 2 +- src/socket/socket.ts | 19 +++- src/socket/targets/SubscribeToPlayers.ts | 32 ++++++- src/socket/targets/targetbase.ts | 14 +-- 23 files changed, 400 insertions(+), 79 deletions(-) create mode 100644 src/data/baseevent.ts create mode 100644 src/data/content/data.ts rename src/data/{content/images.ts => profileevents.ts} (55%) create mode 100644 src/data/steam.ts diff --git a/CONFIG.md b/CONFIG.md index 42b7e6c..d027011 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -2,6 +2,9 @@ [<-- Click to return to README.md](./README.md) +We recommend that you store the configuration file `config.json` in a safe place where Galvanic Corrosion can access it (the current directory).
+No other user on your server system should be able to access the file. + ## Redis Redis is database software and must be installed for Galvanic Corrosion. @@ -41,7 +44,8 @@ This section contains basic information regarding your server. `serverName`: Somewhat invisible to players, but is an official label your server could appear as (to future server lists?) `serverId`: Used in the authentication process, uniquely identifies your server to clients. Players should never see this.
-Ideally, this should be unique for every server, and can be chosen by the server administrator. +Ideally, this should be unique for every server, and can be chosen by the server administrator.
+Example: zombieb-cool-gc-server `owner`: That's you! You can insert your handle for any social networking site or some form of identification here. @@ -57,20 +61,31 @@ Ideally, this should be unique for every server, and can be chosen by the server this can be anything *except* for "none" or 4, since there is only one server to connect to and the game uses offline mode when the region ID is set to none. `initialRoom`: On game startup, redirects the player to this room name instead of their DormRoom. Set to null if a "natural" startup is preferred.
-Ideally, this room should not be private and should be matchmakeable. +This room must not be private and must be matchmakeable. ## Logging -These three values expose booleans you can change to enable/disable logging various messages sent by the server used for debugging or troubleshooting purposes. +These three values expose booleans you can change to enable/disable logging various messages used for debugging or troubleshooting purposes. ## Discord -Currently unused. +Can be `null`. Currently unused. A Discord Bot is planned for interacting with your server outside of the game. ## Auth Parameters used by the server's authentication mechanisms. -`secret`: Used to generate tokens. should never be shared (by extension, the whole file) and can be a string of characters containing no words or patterns. +`secret`: Used to generate tokens. Should never be shared (the entire file) and can be a string of characters containing no words or patterns.
Use secure cryptography APIs in programming languages to generate random strings. -`timeout`: The maximum age for a token. \ No newline at end of file +`timeout`: The maximum age for a token. + +### Auth Verification +Can be `null`. Cloudflare Turnstile is used to verify users before they create their account. + +`enabled`: `true` by default. This section may also be `null`.
+This enables the `GET /user/verify` endpoint that is presented to users wishing to create an account.
+`POST /user/verify` is used for Turnstile siteverify. + +`sitekey`: Turnstile sitekey from Cloudflare dashboard + +`secretkey`: Turnstile secretkey from Cloudflare dashboard \ No newline at end of file diff --git a/README.md b/README.md index e13d490..da9af1a 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,8 @@ Galvanic Corrosion and its contributors are **not** associated in **any** form w * Exit Games Inc. * Against Gravity * Photon Network, Photon Engine, or services associated -* Services in use by or associated with the RecNet platform * Any person(s) in contact with or employed for or with Rec Room Inc. - ## Configuration Read how to configure Galvanic Corrosion [here.](./CONFIG.md) @@ -29,10 +27,4 @@ Read how to configure Galvanic Corrosion [here.](./CONFIG.md) - Configure project - Clone this repo - Install dependencies with `deno i` - - Compile server with `deno run cross-compile` - -## Client Patches - -You can configure some client patches from the server. See the IL2CPP universal -patch for a list of patch IDs. -
Place desired patch ID strings into the config `public.patches`. + - Compile server with `deno run cross-compile` \ No newline at end of file diff --git a/src/apiutils.ts b/src/apiutils.ts index 47c537d..acf6831 100644 --- a/src/apiutils.ts +++ b/src/apiutils.ts @@ -365,7 +365,7 @@ export function startTimer(_rq: express.Request, rs: express.Response, nxt: expr nxt(); } export function stopTimer(_rq: express.Request, rs: express.Response) { - log.n(`(${rs.locals.reqId.substring(0, 11)}) Middleware took ${(performance.now() - rs.locals.timer).toString().substring(0, 6)} ms`); + log.n(`(${rs.locals.reqId}) Middleware took ${(performance.now() - rs.locals.timer).toString().substring(0, 6)} ms`); } export * as APIUtils from "./apiutils.ts"; diff --git a/src/config.ts b/src/config.ts index 3a814be..23976b8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -71,6 +71,7 @@ type AuthConfiguration = { * In Hours */ timeout: number; + steamkey: string | null; }; export type GalvanicConfiguration = { @@ -124,6 +125,7 @@ export const defaultConfig: GalvanicConfiguration = { auth: { secret: "CHANGE-ME-PLEASE", timeout: 3, + steamkey: null }, }; diff --git a/src/data/baseevent.ts b/src/data/baseevent.ts new file mode 100644 index 0000000..3dd0c64 --- /dev/null +++ b/src/data/baseevent.ts @@ -0,0 +1,65 @@ +/* Galvanic Corrosion - Rec Room custom server for communities. + +Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . */ + +import Logging from "@proxnet/undead-logging"; + +const log = new Logging("BaseEvent"); + +export interface Event { + time: Date +} + +export class EventManager { + + private eventCallbacks: Map void>> = new Map(); + + private getSubSet(event: string): Set<(ev: unknown) => void> { + let subset = this.eventCallbacks.get(event); + if (!subset) { + subset = new Set(); + this.eventCallbacks.set(event, subset); + } + return subset; + } + + on(event: string, cb: (ev: T) => void) { + const typeSafeCallback = ((ev: unknown) => { + cb(ev as T); + }); + + this.getSubSet(event).add(typeSafeCallback); + + return typeSafeCallback; + } + + off(event: string, cb: (ev: unknown) => void) { + const subset = this.getSubSet(event); + subset.delete(cb); + } + + emit(event: string, ev: T) { + const subset = this.getSubSet(event); + for (const cb of subset.values()) { + try { + cb(ev); + } catch (err) { + if (err instanceof Error) log.e(`Error when executing callback: ${err.stack}`); + else log.e(`Error when executing callback: ${err}`); + } + } + } +} \ No newline at end of file diff --git a/src/data/config.ts b/src/data/config.ts index eb3beb5..aeefb64 100644 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -67,7 +67,7 @@ export function getConfig() { StartsInMinutes: 0, }, LevelProgressionMaps: generateLevelProgressionMap(), - DailyObjectives: [], + DailyObjectives: [[{type: -1,score:0},{type: -1,score:0},{type: -1,score:0}],[{type: -1,score:0},{type: -1,score:0},{type: -1,score:0}],[{type: -1,score:0},{type: -1,score:0},{type: -1,score:0}]], AutoMicMutingConfig: { MicSpamVolumeThreshold: 1.125, MicVolumeSampleInterval: 0.25, diff --git a/src/data/content/data.ts b/src/data/content/data.ts new file mode 100644 index 0000000..fecb6ec --- /dev/null +++ b/src/data/content/data.ts @@ -0,0 +1,94 @@ +/* Galvanic Corrosion - Rec Room custom server for communities. + +Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . */ + +import { Buffer } from "node:buffer"; +import { Redis } from "../../db.ts"; +import { generateRandomString } from "../../apiutils.ts"; + +export enum FileType { + Unknown, + RoomSave, + Holotar, + Image, + Video, + Invention +} + +export function getFileName(prefix: string, type: FileType) { + switch (type) { + case FileType.RoomSave: + return `${prefix}.room`; + case FileType.Holotar: + return `${prefix}.holotar`; + case FileType.Image: + return `${prefix}.image`; + case FileType.Video: + return `${prefix}.video`; + case FileType.Invention: + return `${prefix}.invention`; + default: + return `${prefix}.unknown` + } +} + +export async function getFile(name: string) { + const data = await Redis.Database.getBuffer(Redis.buildKey( + Redis.KeyGroups.Content.Root, + name + )); + + if (!data) return null; + else return data; +} + +/** + * @returns Name of the new file + */ +export async function setFile(data: Buffer, type: FileType, name?: string) { + let filename = generateRandomString(24); + if (name) filename = name; + const finalName = getFileName(filename, type); + + await Redis.Database.set(Redis.buildKey( + Redis.KeyGroups.Content.Root, + finalName + ), data); + await initFileMeta(filename, type); + return filename; +} + +interface FileMeta { + created: Date | string; + fetchCount: number; + type: FileType +} + +async function initFileMeta(filename: string, type: FileType) { + const meta: FileMeta = { + created: new Date(), + fetchCount: 0, + type: type + } + await Redis.Database.hset(Redis.buildKey( + Redis.KeyGroups.Content.Files, + getFileName(filename, type) + ), meta); +} + +export async function incrementFileFetches(name: string, type: FileType) { + +} \ No newline at end of file diff --git a/src/data/live/instances.ts b/src/data/live/instances.ts index d37151f..eaa60b0 100644 --- a/src/data/live/instances.ts +++ b/src/data/live/instances.ts @@ -20,6 +20,7 @@ import UnifiedProfile, { Profile } from "../profiles.ts"; import { RoomInstance, InstanceOptions } from "./types.ts"; import { Config } from "../../config.ts"; import Presence from "./presence.ts"; +import { RoomFactory } from "../content/rooms/RoomFactory.ts"; const log = new Logging("Instances"); @@ -177,6 +178,9 @@ class InstancesBase { this.updateSingleInstanceIsFull(instance); } else log.w(`Instance ${instance.roomInstanceId} is full. Cannot add player ${player.getId()}`); + + const room = await new RoomFactory({ id: instance.roomId }).init(); + await room?.addVisit(); } playerIsInInstance(player: Profile, instance: RoomInstance) { diff --git a/src/data/profile/reputation.ts b/src/data/profile/reputation.ts index 46d4a1d..450eb21 100644 --- a/src/data/profile/reputation.ts +++ b/src/data/profile/reputation.ts @@ -19,6 +19,7 @@ import { ProfileContentManager } from "./base/profilemanagerbase.ts"; export class ProfileReputationManager extends ProfileContentManager { + // deno-lint-ignore require-await async getReputation() { // async temporary return { AccountId: this.profileId, diff --git a/src/data/content/images.ts b/src/data/profileevents.ts similarity index 55% rename from src/data/content/images.ts rename to src/data/profileevents.ts index c778cdc..7f8e693 100644 --- a/src/data/content/images.ts +++ b/src/data/profileevents.ts @@ -15,27 +15,13 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ -import { Buffer } from "node:buffer"; -import { Redis } from "../../db.ts"; +import { Event } from "./baseevent.ts"; +import { Profile } from "./profiles.ts"; -export async function getImage(filename: string) { - const data = await Redis.Database.get( - Redis.buildKey( - Redis.KeyGroups.Content.Root, - Redis.KeyGroups.Content.Images, - filename, - ), - ); - if (data == null) return null; - else return Buffer.from(data); -} -export async function setImage(filename: string, data: Uint8Array) { - await Redis.Database.set( - Redis.buildKey( - Redis.KeyGroups.Content.Root, - Redis.KeyGroups.Content.Images, - filename, - ), - Buffer.from(data), - ); +export enum ProfileEvents { + BaseUpdated = "profile.updated" } + +export interface ProfileUpdatedEvent extends Event { + profile: Profile +} \ No newline at end of file diff --git a/src/data/profiles.ts b/src/data/profiles.ts index 4d6747d..5e19046 100644 --- a/src/data/profiles.ts +++ b/src/data/profiles.ts @@ -31,6 +31,8 @@ import { ProfileReputationManager } from "./profile/reputation.ts"; import Logging from "@proxnet/undead-logging"; import { ProfileRelationshipManager } from "./profile/relationships.ts"; import { ProfileAvatarManager } from "./profile/avatar.ts"; +import { EventManager } from "./baseevent.ts"; +import { ProfileEvents, ProfileUpdatedEvent } from "./profileevents.ts"; const config = Config.getConfig(); @@ -53,7 +55,7 @@ interface AccountExport { username: string; displayName: string; } -interface SelfAccountExport extends AccountExport { +export interface SelfAccountExport extends AccountExport { email?: string, phone?: string, juniorState?: number, @@ -67,7 +69,7 @@ export interface ProfileTokenFormat extends TokenBaseFormat { const reservedIds = [1, 2]; -class Profile { +class Profile extends EventManager { static async exists(id: number) { return (await Redis.Database.exists( Redis.buildKey( @@ -167,8 +169,7 @@ class Profile { 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], - email: "notanemail@notanemailsite.local" // we are confirmed + displayName: values[3] == null ? (values[2] == null ? "DATABASEERROR" : values[2]) : values[3] }); }); } @@ -196,6 +197,8 @@ class Profile { Avatar: ProfileAvatarManager; constructor(id: number) { + super(); + this.#id = id; this.Settings = new ProfileSettingsManager(this.#id); @@ -205,6 +208,14 @@ class Profile { this.Avatar = new ProfileAvatarManager(this.#id); } + #emitProfileUpdated() { + const ev: ProfileUpdatedEvent = { + time: new Date(), + profile: this + } + this.emit(ProfileEvents.BaseUpdated, ev); + } + setInstance(instance: RoomInstance | null) { this.#instance = instance; } @@ -229,6 +240,21 @@ class Profile { async setBio(bio: string) { await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Bio), bio); + this.#emitProfileUpdated(); + } + + async getDisplayName() { + const name = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DisplayName)); + if (!name) return "DATABASE ERROR"; + else return name; + } + + async setDisplayName(name: string) { + await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.DisplayName), name); + this.#emitProfileUpdated(); + + const acc = await this.export(); + if (acc) this.getSocketHandler()?.sendNotification("AccountUpdate", acc); } async export() { diff --git a/src/data/steam.ts b/src/data/steam.ts new file mode 100644 index 0000000..e2f1564 --- /dev/null +++ b/src/data/steam.ts @@ -0,0 +1,68 @@ +/* Galvanic Corrosion - Rec Room custom server for communities. + +Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published +by the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . */ + +import Logging from "@proxnet/undead-logging"; +import { Config } from "../config.ts"; + +const log = new Logging("Steam"); + +const config = Config.getConfig(); + +interface AuthenticateUserTicketSuccess { + result: 'OK', + steamid: string, + ownersteamid: string, + vacbanned: boolean, + publisherbanned: boolean +} +interface AuthenticateUserTicketError { + errorcode: number, + errordesc: string +} + +interface SteamRes { + response: { + error?: AuthenticateUserTicketError, + params?: AuthenticateUserTicketSuccess + } +} + +export async function AuthenticateUserTicket(ticket: string, userid: string) { + if (!config.auth.steamkey) return true; // always authenticate if no steam API key was found + + const params = new URLSearchParams(); + params.append('key', config.auth.steamkey); + params.append('appid', "471710"); + params.append('ticket', ticket); + + const res = await fetch(`https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1?${params}`); + const resjson = (await res.json()) as SteamRes; + + if (resjson.response.error) { + log.w(`Steam Authentication failed: (${resjson.response.error.errorcode}) ${resjson.response.error.errordesc}`); + return false; + } + + log.d(JSON.stringify(resjson.response)); + if (resjson.response.params) return resjson.response.params.steamid === userid && resjson.response.params.ownersteamid === userid; + else { + log.w("Steam Authentication failed: Steam response did not contain params or error! This should never be logged!"); + return false; + } +} + +export * as Steam from "./steam.ts"; \ No newline at end of file diff --git a/src/data/users.ts b/src/data/users.ts index 3ac7413..14d72df 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -24,6 +24,7 @@ import { TokenBaseFormat } from "../apiutils.ts"; type UserInitOptions = { client_id: string; pubkey: string; + captcha?: string; }; export enum AuthType { diff --git a/src/db.ts b/src/db.ts index 2261ad4..4395d93 100644 --- a/src/db.ts +++ b/src/db.ts @@ -71,8 +71,7 @@ export const KeyGroups = { }, Content: { Root: "content", - Images: "images", - Rooms: "rooms", + Files: "file-meta", }, Profile_Usernames: "profile-usernames", PlatformAssociations: "platforms", diff --git a/src/main.ts b/src/main.ts index bd7af02..88f9b15 100644 --- a/src/main.ts +++ b/src/main.ts @@ -29,6 +29,7 @@ import { SocketHandoff } from "./socket/handoff.ts"; import { SignalRSocketHandler } from "./socket/socket.ts"; import Rooms from "./data/content/rooms.ts"; import { GameConfigs } from "./data/config.ts"; +import { RootPath } from "./data/content/baseimages.ts"; const instanceId = generateRandomString(64); @@ -39,16 +40,16 @@ log.i(`Starting Galvanic Corrosion..`); const config = Config.getConfig(); if (typeof config == "undefined") { - log.e("Cannot start: Configuration is undefined"); - Deno.exit(1); + log.e("Cannot start: Configuration was not found."); + Deno.exit(5); } if (config.auth.secret == Config.defaultConfig.auth.secret) { log.e(`Cannot start: Auth secret is default. Please change 'auth.secret' in 'config.json'`); - Deno.exit(1); + Deno.exit(5); } if (config.public.serverId == Config.defaultConfig.public.serverId) { log.e(`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`); - Deno.exit(1); + Deno.exit(5); } Log.MessageTypeVisibility.Network = config.logging.network; @@ -105,6 +106,8 @@ app.use(matchRouter.route.path, matchRouter.route.router); app.use(notifyRouter.route.path, notifyRouter.route.router); app.use(cdnRouter.route.path, cdnRouter.route.router); +// end content routes + app.use((rq: express.Request, rs: express.Response) => { log.e(`${rs.locals.reqId} ${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`); rs.statusCode = 404; @@ -149,14 +152,6 @@ try { const abort = new AbortController(); - abort.signal.addEventListener('abort', () => { - log.n("Closing all sockets"); - const sockets = UnifiedProfile.getAllSockets(); - for (const socket of sockets.values()) { - socket.destroy(socket)(); - } // I used the socket to destroy the socket - }); - // Galvanic WebSocket Deno.serve({port: config.web.socket.port, hostname: config.web.socket.host, signal: abort.signal, onListen: addr => { log.n(`Socket listening on http://${addr.hostname}:${addr.port}`); diff --git a/src/routes/account/account.ts b/src/routes/account/account.ts index 367ba9c..7e293c9 100644 --- a/src/routes/account/account.ts +++ b/src/routes/account/account.ts @@ -103,12 +103,25 @@ route.router.get("/me", ); -route.router.post("/me/displayname", +interface DisplayNameUpdate { + displayName: string +} +const DisplayNameUpdateSchema = z.object({ + displayName: z.string().max(24, "DisplayName too long!") +}) +route.router.put("/me/displayname", APIUtils.Authentication, APIUtils.AuthenticationType(AuthType.Game), + express.urlencoded({ extended: true }), + APIUtils.validateRequestBody(DisplayNameUpdateSchema), + + (rq: express.Request<{}, {}, DisplayNameUpdate>, rs: express.Response, nxt: express.NextFunction) => { + rs.locals.profile.setDisplayName(rq.body.displayName); + nxt(); + }, - APIUtils.RecNetResponse(true, "DisplayName customization is not yet implemented."), + APIUtils.RecNetResponse(true, "Updated DisplayName.") ); diff --git a/src/routes/auth/account.ts b/src/routes/auth/account.ts index 5c3149c..5225e4e 100644 --- a/src/routes/auth/account.ts +++ b/src/routes/auth/account.ts @@ -34,7 +34,7 @@ route.router.get('/me/haspassword', APIUtils.Authentication, APIUtils.AuthenticationType(AuthType.Game), - (rq, rs) => { + (_rq, rs) => { rs.json(true); }, diff --git a/src/routes/auth/connect.ts b/src/routes/auth/connect.ts index 7973ffe..3055bba 100644 --- a/src/routes/auth/connect.ts +++ b/src/routes/auth/connect.ts @@ -25,6 +25,7 @@ import { z } from "zod"; import { AuthType } from "../../data/users.ts"; import { Redis } from "../../db.ts"; import { validVersions } from "../api/versioncheck.ts"; +import { Steam } from "../../data/steam.ts"; const config = Config.getConfig(); @@ -53,9 +54,18 @@ interface RefreshRequest extends AuthBodyBase { refresh_token: string, grant_type: "refresh_token" } +interface SteamPlatformParams { + Ticket: string, + AppId: string +} type TokenRequestBody = TokenRequest | RefreshRequest; +const SteamPlatformParamsSchema = z.object({ + Ticket: z.string(), + AppId: z.literal('471710') +}); + const AuthBodyBaseSchema = z.object({ grant_type: z.string(), client_id: z.string(), @@ -98,6 +108,7 @@ route.router.post("/token", APIUtils.Authentication, APIUtils.AuthenticationType(AuthType.Web), express.urlencoded({ extended: true }), + APIUtils.logBody, APIUtils.validateRequestBody(TokenRequestBodySchema), async ( @@ -125,6 +136,7 @@ route.router.post("/token", !(rq.body.platform_id.length > 32), !(rq.body.time.length > 32), !(rq.body.asid.length > 32), + SteamPlatformParamsSchema.safeParse(JSON.parse(rq.body.platform_auth)).success ].includes(false); if (!conditionsMet) { @@ -153,6 +165,16 @@ route.router.post("/token", targetAccount = parseInt(decodedToken.sub ? decodedToken.sub : "NaN"); } + + const platformAuth = (JSON.parse(rq.body.platform_auth)) as SteamPlatformParams; + + if (config.auth.steamkey) { + const steamAuthed = await Steam.AuthenticateUserTicket(platformAuth.Ticket, rq.body.platform_id); + if (!steamAuthed) { + requestFailed(); + return; + } + } if (isNaN(targetAccount)) { requestFailed(); diff --git a/src/routes/img.ts b/src/routes/img.ts index 63c8c9b..e9ff74c 100644 --- a/src/routes/img.ts +++ b/src/routes/img.ts @@ -18,7 +18,6 @@ along with this program. If not, see . */ import { APIUtils, NoBody } from "../apiutils.ts"; import * as BaseImages from "../data/content/baseimages.ts"; import express from "express"; -import * as Images from "./../data/content/images.ts"; import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts"; import { Buffer } from "node:buffer"; @@ -30,8 +29,6 @@ function sanitizeString(input: string) { return input.split("").filter((char) => chars.includes(char)).join(""); } -const baseImages = BaseImages.getAllBaseImages(); - interface ImageQueryOptions { cropSquare?: string; width?: string; @@ -50,10 +47,12 @@ route.router.get( rq.path.substring(1, rq.path.length).replaceAll("%20", " "), ); + // deno-lint-ignore prefer-const let image: Image; - const imageSource = baseImages.includes(filename) + /*const imageSource = baseImages.includes(filename) ? BaseImages.getBaseImage(filename) - : await Images.getImage(filename); + : await Images.getImage(filename);*/ + const imageSource = BaseImages.getBaseImage(filename); if (imageSource == null) { nxt(); return; diff --git a/src/routes/user.ts b/src/routes/user.ts index b9ec304..090d562 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -129,7 +129,7 @@ route.router.post("/auth", } else user = obj; } if (!(await user.addNonce(rq.body.message.nonce))) { - log.w(`Client '${rq.body.client_id}' has already used nonce. Replay attack?`); + log.w(`Client '${rq.body.client_id}' has already used nonce.`); authFailed("Authentication request failed."); return; } diff --git a/src/socket/socket.ts b/src/socket/socket.ts index 68a2291..ed5a9a4 100644 --- a/src/socket/socket.ts +++ b/src/socket/socket.ts @@ -34,6 +34,10 @@ import { import { SocketTarget } from "./targets/targetbase.ts"; import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; import Presence from "../data/live/presence.ts"; +import Instances from "../data/live/instances.ts"; +import Matchmaking from "../data/live/base.ts"; + +const logmessages = true; export class SignalRSocketHandler { @@ -55,7 +59,7 @@ export class SignalRSocketHandler { player.setSocketHandler(this); - this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget()); + this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget(this)); this.#PeriodicalId = setInterval(async () => { if (this.#socket.readyState !== this.#socket.CLOSED) { @@ -86,7 +90,7 @@ export class SignalRSocketHandler { this.sendRaw({}); return; } else { - //this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`); + if (logmessages) 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) { @@ -153,14 +157,21 @@ export class SignalRSocketHandler { if (!internal) sock.#socket.close(); sock.#log.i(`Closed socket`); sock.#profile.clearSocketHandler(); + + for (const target of sock.#Targets.values()) target.destroy(); + + Instances.removePlayerFromCurrentInstance(this.#profile); + Matchmaking.deleteLoginLock(this.#profile); } } sendRaw(data: object) { this.#socket.send(`${JSON.stringify(data)}\u001e`); // todo sometime: make the below 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)}`); + if (logmessages) { + 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)}`); + } } sendNotification(id: PushNotificationId | string, args: object) { diff --git a/src/socket/targets/SubscribeToPlayers.ts b/src/socket/targets/SubscribeToPlayers.ts index 83c00af..28777ae 100644 --- a/src/socket/targets/SubscribeToPlayers.ts +++ b/src/socket/targets/SubscribeToPlayers.ts @@ -17,6 +17,9 @@ along with this program. If not, see . */ import { z } from "zod"; import { SocketTarget } from "./targetbase.ts"; +import UnifiedProfile, { SelfAccountExport } from "../../data/profiles.ts"; +import { ProfileEvents, ProfileUpdatedEvent } from "../../data/profileevents.ts"; +import { PushNotificationId } from "../types.ts"; const ArgumentSchema = z.object({ PlayerIds: z.array(z.number()) @@ -24,10 +27,35 @@ const ArgumentSchema = z.object({ export class PlayerSocketSubscriptionTarget extends SocketTarget { - subscriptions: number[] = []; + updateSocket(profile: SelfAccountExport) { + this.socket.sendNotification(PushNotificationId.SubscriptionUpdateProfile, profile); + } + + subscriptions: { id: number, callback: (ev: unknown) => void }[] = []; setSubscriptions(subs: number[]) { - this.subscriptions = subs; + + this.clearSubscriptions(); + + for (const id of subs) + this.subscriptions.push({ id: id, callback: UnifiedProfile.get(id) + .on(ProfileEvents.BaseUpdated, (async ev => { + const exported = await ev.profile.export(); + if (exported) this.updateSocket(exported); + } + )) }); + + } + + clearSubscriptions() { + for (const sub of this.subscriptions) { + const profile = UnifiedProfile.get(sub.id); + profile.off(ProfileEvents.BaseUpdated, sub.callback); + } + } + + override destroy() { + this.clearSubscriptions(); } // deno-lint-ignore require-await diff --git a/src/socket/targets/targetbase.ts b/src/socket/targets/targetbase.ts index 69133da..be38d8d 100644 --- a/src/socket/targets/targetbase.ts +++ b/src/socket/targets/targetbase.ts @@ -15,18 +15,18 @@ GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ +import { SignalRSocketHandler } from "../socket.ts"; + export class SocketTarget { - profileNotSetError = new Error("The profile on this target is not set."); + socket: SignalRSocketHandler; - profileId: number | null = null; - - setProfile(id: number) { - this.profileId = id; + constructor(socket: SignalRSocketHandler) { + this.socket = socket; } - profileIsSet() { - return this.profileId !== null; + destroy() { + return; } // deno-lint-ignore require-await