my power's back
All checks were successful
Galvanic Corrosion Cross-Compile / build (push) Successful in 1m57s

* Steam authentication
* Profile events
* Objective fixes for some rooms (reccenter, goldentrophy, etc)
* Profile meta (displayname) somewhat works
* Player socket subscriptions
This commit is contained in:
2025-05-04 02:25:09 -04:00
parent fe29602f4a
commit 9e4bfc8368
23 changed files with 400 additions and 79 deletions

View File

@@ -2,6 +2,9 @@
[<-- Click to return to README.md](./README.md) [<-- 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).<br>
No other user on your server system should be able to access the file.
## Redis ## Redis
Redis is database software and must be installed for Galvanic Corrosion. 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?) `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.<br> `serverId`: Used in the authentication process, uniquely identifies your server to clients. Players should never see this.<br>
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.<br>
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. `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. 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.<br> `initialRoom`: On game startup, redirects the player to this room name instead of their DormRoom. Set to null if a "natural" startup is preferred.<br>
Ideally, this room should not be private and should be matchmakeable. This room must not be private and must be matchmakeable.
## Logging ## 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 ## Discord
Currently unused. Can be `null`. Currently unused.
A Discord Bot is planned for interacting with your server outside of the game. A Discord Bot is planned for interacting with your server outside of the game.
## Auth ## Auth
Parameters used by the server's authentication mechanisms. 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.
<br>Use secure cryptography APIs in programming languages to generate random strings. <br>Use secure cryptography APIs in programming languages to generate random strings.
`timeout`: The maximum age for a token. `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`.<br>
This enables the `GET /user/verify` endpoint that is presented to users wishing to create an account.<br>
`POST /user/verify` is used for Turnstile siteverify.
`sitekey`: Turnstile sitekey from Cloudflare dashboard
`secretkey`: Turnstile secretkey from Cloudflare dashboard

View File

@@ -14,10 +14,8 @@ Galvanic Corrosion and its contributors are **not** associated in **any** form w
* Exit Games Inc. * Exit Games Inc.
* Against Gravity * Against Gravity
* Photon Network, Photon Engine, or services associated * 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. * Any person(s) in contact with or employed for or with Rec Room Inc.
## Configuration ## Configuration
Read how to configure Galvanic Corrosion [here.](./CONFIG.md) Read how to configure Galvanic Corrosion [here.](./CONFIG.md)
@@ -30,9 +28,3 @@ Read how to configure Galvanic Corrosion [here.](./CONFIG.md)
- Clone this repo - Clone this repo
- Install dependencies with `deno i` - Install dependencies with `deno i`
- Compile server with `deno run cross-compile` - 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.
<br>Place desired patch ID strings into the config `public.patches`.

View File

@@ -365,7 +365,7 @@ export function startTimer(_rq: express.Request, rs: express.Response, nxt: expr
nxt(); nxt();
} }
export function stopTimer(_rq: express.Request, rs: express.Response) { 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"; export * as APIUtils from "./apiutils.ts";

View File

@@ -71,6 +71,7 @@ type AuthConfiguration = {
* In Hours * In Hours
*/ */
timeout: number; timeout: number;
steamkey: string | null;
}; };
export type GalvanicConfiguration = { export type GalvanicConfiguration = {
@@ -124,6 +125,7 @@ export const defaultConfig: GalvanicConfiguration = {
auth: { auth: {
secret: "CHANGE-ME-PLEASE", secret: "CHANGE-ME-PLEASE",
timeout: 3, timeout: 3,
steamkey: null
}, },
}; };

65
src/data/baseevent.ts Normal file
View File

@@ -0,0 +1,65 @@
/* Galvanic Corrosion - Rec Room custom server for communities.
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
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 <https://www.gnu.org/licenses/>. */
import Logging from "@proxnet/undead-logging";
const log = new Logging("BaseEvent");
export interface Event {
time: Date
}
export class EventManager {
private eventCallbacks: Map<string, Set<(ev: unknown) => 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<T extends Event>(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<T extends Event>(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}`);
}
}
}
}

View File

@@ -67,7 +67,7 @@ export function getConfig() {
StartsInMinutes: 0, StartsInMinutes: 0,
}, },
LevelProgressionMaps: generateLevelProgressionMap(), 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: { AutoMicMutingConfig: {
MicSpamVolumeThreshold: 1.125, MicSpamVolumeThreshold: 1.125,
MicVolumeSampleInterval: 0.25, MicVolumeSampleInterval: 0.25,

94
src/data/content/data.ts Normal file
View File

@@ -0,0 +1,94 @@
/* Galvanic Corrosion - Rec Room custom server for communities.
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
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 <https://www.gnu.org/licenses/>. */
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<ArrayBufferLike>, 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) {
}

View File

@@ -20,6 +20,7 @@ import UnifiedProfile, { Profile } from "../profiles.ts";
import { RoomInstance, InstanceOptions } from "./types.ts"; import { RoomInstance, InstanceOptions } from "./types.ts";
import { Config } from "../../config.ts"; import { Config } from "../../config.ts";
import Presence from "./presence.ts"; import Presence from "./presence.ts";
import { RoomFactory } from "../content/rooms/RoomFactory.ts";
const log = new Logging("Instances"); const log = new Logging("Instances");
@@ -177,6 +178,9 @@ class InstancesBase {
this.updateSingleInstanceIsFull(instance); this.updateSingleInstanceIsFull(instance);
} else log.w(`Instance ${instance.roomInstanceId} is full. Cannot add player ${player.getId()}`); } 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) { playerIsInInstance(player: Profile, instance: RoomInstance) {

View File

@@ -19,6 +19,7 @@ import { ProfileContentManager } from "./base/profilemanagerbase.ts";
export class ProfileReputationManager extends ProfileContentManager { export class ProfileReputationManager extends ProfileContentManager {
// deno-lint-ignore require-await
async getReputation() { // async temporary async getReputation() { // async temporary
return { return {
AccountId: this.profileId, AccountId: this.profileId,

View File

@@ -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 You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { Buffer } from "node:buffer"; import { Event } from "./baseevent.ts";
import { Redis } from "../../db.ts"; import { Profile } from "./profiles.ts";
export async function getImage(filename: string) { export enum ProfileEvents {
const data = await Redis.Database.get( BaseUpdated = "profile.updated"
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( export interface ProfileUpdatedEvent extends Event {
Redis.buildKey( profile: Profile
Redis.KeyGroups.Content.Root,
Redis.KeyGroups.Content.Images,
filename,
),
Buffer.from(data),
);
} }

View File

@@ -31,6 +31,8 @@ import { ProfileReputationManager } from "./profile/reputation.ts";
import Logging from "@proxnet/undead-logging"; import Logging from "@proxnet/undead-logging";
import { ProfileRelationshipManager } from "./profile/relationships.ts"; import { ProfileRelationshipManager } from "./profile/relationships.ts";
import { ProfileAvatarManager } from "./profile/avatar.ts"; import { ProfileAvatarManager } from "./profile/avatar.ts";
import { EventManager } from "./baseevent.ts";
import { ProfileEvents, ProfileUpdatedEvent } from "./profileevents.ts";
const config = Config.getConfig(); const config = Config.getConfig();
@@ -53,7 +55,7 @@ interface AccountExport {
username: string; username: string;
displayName: string; displayName: string;
} }
interface SelfAccountExport extends AccountExport { export interface SelfAccountExport extends AccountExport {
email?: string, email?: string,
phone?: string, phone?: string,
juniorState?: number, juniorState?: number,
@@ -67,7 +69,7 @@ export interface ProfileTokenFormat extends TokenBaseFormat {
const reservedIds = [1, 2]; const reservedIds = [1, 2];
class Profile { class Profile extends EventManager {
static async exists(id: number) { static async exists(id: number) {
return (await Redis.Database.exists( return (await Redis.Database.exists(
Redis.buildKey( Redis.buildKey(
@@ -167,8 +169,7 @@ class Profile {
isJunior: values[1] == null ? false : JSON.parse(values[1]), isJunior: values[1] == null ? false : JSON.parse(values[1]),
platforms: 1, platforms: 1,
username: values[2] == null ? "DATABASEERROR" : values[2], username: values[2] == null ? "DATABASEERROR" : values[2],
displayName: values[3] == null ? (values[2] == null ? "DATABASEERROR" : values[2]) : values[3], displayName: values[3] == null ? (values[2] == null ? "DATABASEERROR" : values[2]) : values[3]
email: "notanemail@notanemailsite.local" // we are confirmed
}); });
}); });
} }
@@ -196,6 +197,8 @@ class Profile {
Avatar: ProfileAvatarManager; Avatar: ProfileAvatarManager;
constructor(id: number) { constructor(id: number) {
super();
this.#id = id; this.#id = id;
this.Settings = new ProfileSettingsManager(this.#id); this.Settings = new ProfileSettingsManager(this.#id);
@@ -205,6 +208,14 @@ class Profile {
this.Avatar = new ProfileAvatarManager(this.#id); this.Avatar = new ProfileAvatarManager(this.#id);
} }
#emitProfileUpdated() {
const ev: ProfileUpdatedEvent = {
time: new Date(),
profile: this
}
this.emit(ProfileEvents.BaseUpdated, ev);
}
setInstance(instance: RoomInstance | null) { setInstance(instance: RoomInstance | null) {
this.#instance = instance; this.#instance = instance;
} }
@@ -229,6 +240,21 @@ class Profile {
async setBio(bio: string) { async setBio(bio: string) {
await Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Profiles.Root, this.#id.toString(), Redis.KeyGroups.Profiles.Bio), bio); 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() { async export() {

68
src/data/steam.ts Normal file
View File

@@ -0,0 +1,68 @@
/* Galvanic Corrosion - Rec Room custom server for communities.
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
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 <https://www.gnu.org/licenses/>. */
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";

View File

@@ -24,6 +24,7 @@ import { TokenBaseFormat } from "../apiutils.ts";
type UserInitOptions = { type UserInitOptions = {
client_id: string; client_id: string;
pubkey: string; pubkey: string;
captcha?: string;
}; };
export enum AuthType { export enum AuthType {

View File

@@ -71,8 +71,7 @@ export const KeyGroups = {
}, },
Content: { Content: {
Root: "content", Root: "content",
Images: "images", Files: "file-meta",
Rooms: "rooms",
}, },
Profile_Usernames: "profile-usernames", Profile_Usernames: "profile-usernames",
PlatformAssociations: "platforms", PlatformAssociations: "platforms",

View File

@@ -29,6 +29,7 @@ import { SocketHandoff } from "./socket/handoff.ts";
import { SignalRSocketHandler } from "./socket/socket.ts"; import { SignalRSocketHandler } from "./socket/socket.ts";
import Rooms from "./data/content/rooms.ts"; import Rooms from "./data/content/rooms.ts";
import { GameConfigs } from "./data/config.ts"; import { GameConfigs } from "./data/config.ts";
import { RootPath } from "./data/content/baseimages.ts";
const instanceId = generateRandomString(64); const instanceId = generateRandomString(64);
@@ -39,16 +40,16 @@ log.i(`Starting Galvanic Corrosion..`);
const config = Config.getConfig(); const config = Config.getConfig();
if (typeof config == "undefined") { if (typeof config == "undefined") {
log.e("Cannot start: Configuration is undefined"); log.e("Cannot start: Configuration was not found.");
Deno.exit(1); Deno.exit(5);
} }
if (config.auth.secret == Config.defaultConfig.auth.secret) { if (config.auth.secret == Config.defaultConfig.auth.secret) {
log.e(`Cannot start: Auth secret is default. Please change 'auth.secret' in 'config.json'`); 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) { 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); Deno.exit(5);
} }
Log.MessageTypeVisibility.Network = config.logging.network; 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(notifyRouter.route.path, notifyRouter.route.router);
app.use(cdnRouter.route.path, cdnRouter.route.router); app.use(cdnRouter.route.path, cdnRouter.route.router);
// end content routes
app.use((rq: express.Request, rs: express.Response) => { app.use((rq: express.Request, rs: express.Response) => {
log.e(`${rs.locals.reqId} ${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`); log.e(`${rs.locals.reqId} ${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
rs.statusCode = 404; rs.statusCode = 404;
@@ -149,14 +152,6 @@ try {
const abort = new AbortController(); 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 // Galvanic WebSocket
Deno.serve({port: config.web.socket.port, hostname: config.web.socket.host, signal: abort.signal, onListen: addr => { 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}`); log.n(`Socket listening on http://${addr.hostname}:${addr.port}`);

View File

@@ -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.Authentication,
APIUtils.AuthenticationType(AuthType.Game), APIUtils.AuthenticationType(AuthType.Game),
express.urlencoded({ extended: true }),
APIUtils.validateRequestBody(DisplayNameUpdateSchema),
APIUtils.RecNetResponse(true, "DisplayName customization is not yet implemented."), (rq: express.Request<{}, {}, DisplayNameUpdate>, rs: express.Response, nxt: express.NextFunction) => {
rs.locals.profile.setDisplayName(rq.body.displayName);
nxt();
},
APIUtils.RecNetResponse(true, "Updated DisplayName.")
); );

View File

@@ -34,7 +34,7 @@ route.router.get('/me/haspassword',
APIUtils.Authentication, APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Game), APIUtils.AuthenticationType(AuthType.Game),
(rq, rs) => { (_rq, rs) => {
rs.json(true); rs.json(true);
}, },

View File

@@ -25,6 +25,7 @@ import { z } from "zod";
import { AuthType } from "../../data/users.ts"; import { AuthType } from "../../data/users.ts";
import { Redis } from "../../db.ts"; import { Redis } from "../../db.ts";
import { validVersions } from "../api/versioncheck.ts"; import { validVersions } from "../api/versioncheck.ts";
import { Steam } from "../../data/steam.ts";
const config = Config.getConfig(); const config = Config.getConfig();
@@ -53,9 +54,18 @@ interface RefreshRequest extends AuthBodyBase {
refresh_token: string, refresh_token: string,
grant_type: "refresh_token" grant_type: "refresh_token"
} }
interface SteamPlatformParams {
Ticket: string,
AppId: string
}
type TokenRequestBody = TokenRequest | RefreshRequest; type TokenRequestBody = TokenRequest | RefreshRequest;
const SteamPlatformParamsSchema = z.object({
Ticket: z.string(),
AppId: z.literal('471710')
});
const AuthBodyBaseSchema = z.object({ const AuthBodyBaseSchema = z.object({
grant_type: z.string(), grant_type: z.string(),
client_id: z.string(), client_id: z.string(),
@@ -98,6 +108,7 @@ route.router.post("/token",
APIUtils.Authentication, APIUtils.Authentication,
APIUtils.AuthenticationType(AuthType.Web), APIUtils.AuthenticationType(AuthType.Web),
express.urlencoded({ extended: true }), express.urlencoded({ extended: true }),
APIUtils.logBody,
APIUtils.validateRequestBody<AuthBodyBase>(TokenRequestBodySchema), APIUtils.validateRequestBody<AuthBodyBase>(TokenRequestBodySchema),
async ( async (
@@ -125,6 +136,7 @@ route.router.post("/token",
!(rq.body.platform_id.length > 32), !(rq.body.platform_id.length > 32),
!(rq.body.time.length > 32), !(rq.body.time.length > 32),
!(rq.body.asid.length > 32), !(rq.body.asid.length > 32),
SteamPlatformParamsSchema.safeParse(JSON.parse(rq.body.platform_auth)).success
].includes(false); ].includes(false);
if (!conditionsMet) { if (!conditionsMet) {
@@ -154,6 +166,16 @@ route.router.post("/token",
} }
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)) { if (isNaN(targetAccount)) {
requestFailed(); requestFailed();
return; return;

View File

@@ -18,7 +18,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { APIUtils, NoBody } from "../apiutils.ts"; import { APIUtils, NoBody } from "../apiutils.ts";
import * as BaseImages from "../data/content/baseimages.ts"; import * as BaseImages from "../data/content/baseimages.ts";
import express from "express"; 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 { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts";
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
@@ -30,8 +29,6 @@ function sanitizeString(input: string) {
return input.split("").filter((char) => chars.includes(char)).join(""); return input.split("").filter((char) => chars.includes(char)).join("");
} }
const baseImages = BaseImages.getAllBaseImages();
interface ImageQueryOptions { interface ImageQueryOptions {
cropSquare?: string; cropSquare?: string;
width?: string; width?: string;
@@ -50,10 +47,12 @@ route.router.get(
rq.path.substring(1, rq.path.length).replaceAll("%20", " "), rq.path.substring(1, rq.path.length).replaceAll("%20", " "),
); );
// deno-lint-ignore prefer-const
let image: Image; let image: Image;
const imageSource = baseImages.includes(filename) /*const imageSource = baseImages.includes(filename)
? BaseImages.getBaseImage(filename) ? BaseImages.getBaseImage(filename)
: await Images.getImage(filename); : await Images.getImage(filename);*/
const imageSource = BaseImages.getBaseImage(filename);
if (imageSource == null) { if (imageSource == null) {
nxt(); nxt();
return; return;

View File

@@ -129,7 +129,7 @@ route.router.post("/auth",
} else user = obj; } else user = obj;
} }
if (!(await user.addNonce(rq.body.message.nonce))) { 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."); authFailed("Authentication request failed.");
return; return;
} }

View File

@@ -34,6 +34,10 @@ import {
import { SocketTarget } from "./targets/targetbase.ts"; import { SocketTarget } from "./targets/targetbase.ts";
import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts";
import Presence from "../data/live/presence.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 { export class SignalRSocketHandler {
@@ -55,7 +59,7 @@ export class SignalRSocketHandler {
player.setSocketHandler(this); player.setSocketHandler(this);
this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget()); this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget(this));
this.#PeriodicalId = setInterval(async () => { this.#PeriodicalId = setInterval(async () => {
if (this.#socket.readyState !== this.#socket.CLOSED) { if (this.#socket.readyState !== this.#socket.CLOSED) {
@@ -86,7 +90,7 @@ export class SignalRSocketHandler {
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)}`); 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 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 const res = await this.#dispatchTarget(message.data.target, message.data.arguments[0]); // rec room only uses the first index
if (res.type == TargetResultType.Success) { if (res.type == TargetResultType.Success) {
@@ -153,14 +157,21 @@ export class SignalRSocketHandler {
if (!internal) sock.#socket.close(); if (!internal) sock.#socket.close();
sock.#log.i(`Closed socket`); sock.#log.i(`Closed socket`);
sock.#profile.clearSocketHandler(); sock.#profile.clearSocketHandler();
for (const target of sock.#Targets.values()) target.destroy();
Instances.removePlayerFromCurrentInstance(this.#profile);
Matchmaking.deleteLoginLock(this.#profile);
} }
} }
sendRaw(data: object) { sendRaw(data: object) {
this.#socket.send(`${JSON.stringify(data)}\u001e`); this.#socket.send(`${JSON.stringify(data)}\u001e`);
// todo sometime: make the below less confusing // todo sometime: make the below less confusing
//const type = `Type: ${JSON.stringify(data) == '{}' ? 'Protocol Message' : `${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`}`; if (logmessages) {
//this.#log.d(`SERVER MESSAGE\n ${type}\n ${JSON.stringify(data as SignalRMessage)}`); 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) { sendNotification(id: PushNotificationId | string, args: object) {

View File

@@ -17,6 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { z } from "zod"; import { z } from "zod";
import { SocketTarget } from "./targetbase.ts"; 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({ const ArgumentSchema = z.object({
PlayerIds: z.array(z.number()) PlayerIds: z.array(z.number())
@@ -24,10 +27,35 @@ const ArgumentSchema = z.object({
export class PlayerSocketSubscriptionTarget extends SocketTarget { 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[]) { setSubscriptions(subs: number[]) {
this.subscriptions = subs;
this.clearSubscriptions();
for (const id of subs)
this.subscriptions.push({ id: id, callback: UnifiedProfile.get(id)
.on<ProfileUpdatedEvent>(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 // deno-lint-ignore require-await

View File

@@ -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 You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */ along with this program. If not, see <https://www.gnu.org/licenses/>. */
import { SignalRSocketHandler } from "../socket.ts";
export class SocketTarget { export class SocketTarget {
profileNotSetError = new Error("The profile on this target is not set."); socket: SignalRSocketHandler;
profileId: number | null = null; constructor(socket: SignalRSocketHandler) {
this.socket = socket;
setProfile(id: number) {
this.profileId = id;
} }
profileIsSet() { destroy() {
return this.profileId !== null; return;
} }
// deno-lint-ignore require-await // deno-lint-ignore require-await