From c2eb1112910883cce3561629edb4b8700f016e71 Mon Sep 17 00:00:00 2001 From: zombieb Date: Thu, 11 Sep 2025 19:47:33 -0400 Subject: [PATCH] woah man --- src/main.ts | 10 ++++-- src/routes/accounts/routes/account.ts | 22 ++++++------- src/routes/accounts/routes/me/root.ts | 38 ++++++++++++++++++++++ src/routes/api/routes/avatar.ts | 5 +-- src/routes/api/routes/config.ts | 19 +++++++++++ src/routes/api/routes/rooms.ts | 6 ++++ src/routes/auth/routes/account.ts | 32 +++++++++++++++++++ src/routes/auth/routes/connect.ts | 7 ++-- src/server/presence/base.ts | 5 ++- src/server/profiles/manager.ts | 31 ++++++++++++------ src/server/profiles/profile.ts | 14 ++++++-- src/util/api.ts | 14 +++----- src/util/flags.ts | 46 +++++++++++++++++++++++++++ src/util/validators.ts | 12 +++++-- 14 files changed, 217 insertions(+), 44 deletions(-) create mode 100644 src/routes/accounts/routes/me/root.ts create mode 100644 src/routes/auth/routes/account.ts create mode 100644 src/util/flags.ts diff --git a/src/main.ts b/src/main.ts index f6ef443..35d0c4f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ import { SignalRSocketHandler } from "./server/socket/signalr/socket.ts"; import { PushNotificationId } from "./server/socket/signalr/types.ts"; import { genericResponse } from "./util/api.ts"; import { getNetConfig } from "./net.ts"; -import { TokenFormat, TokenType } from "./server/platforms/types.ts"; +import { PlatformType, TokenFormat, TokenType } from "./server/platforms/types.ts"; import { HonoEnv } from "./util/types.ts"; import { Context } from "@hono/hono"; import { compress } from "@hono/hono/compress"; @@ -185,4 +185,10 @@ Server.Commands.addRootCommand(new Command({ z.coerce.number() ]), help: 'Get ping (in ms) to the server' -})); \ No newline at end of file +})); + +Server.on('server.start', () => { + Server.Profiles.get(1).then(prof => { + if (!prof) Server.Profiles.create(PlatformType.HeadlessBot, "", "Coach", 1); + }); +}); \ No newline at end of file diff --git a/src/routes/accounts/routes/account.ts b/src/routes/accounts/routes/account.ts index 2d468b4..40df9d2 100644 --- a/src/routes/accounts/routes/account.ts +++ b/src/routes/accounts/routes/account.ts @@ -1,11 +1,12 @@ import { createHonoRoute } from "../../../util/import.ts"; -import { authenticate, galvanicError, GalvanicErrors, RateLimiter, recNetError, statusResponse } from "../../../util/api.ts"; +import { RateLimiter, recNetResultResponse, statusResponse } from "../../../util/api.ts"; import Server from "../../../server/server.ts"; import z from "zod"; import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts"; import { PlatformType } from "../../../server/platforms/types.ts"; import Steam from "../../../util/steam/steam.ts"; import { HTTPStatus } from "@oneday/http-status"; +import { accountRoute } from "./me/root.ts"; export const route = createHonoRoute('/account'); @@ -35,20 +36,22 @@ const createAccountBodySchema = z.object({ }); route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form', createAccountBodySchema), async c => { + if (c.req.header("User-Agent") !== "BestHTTP") return recNetResultResponse(c, HTTPStatus.OK, false, "Platform error"); + const form = c.req.valid('form'); - if (typeof form.platform == 'undefined') - return c.json(galvanicError(GalvanicErrors.jex)); + if (form.platform == null) + return recNetResultResponse(c, HTTPStatus.OK, false, "Platform error"); else if (form.platform == PlatformType.Steam) { const steam = await Steam.GetPlayerSummaries([form.platformId]); if (steam.length == 0) - return c.json(galvanicError(GalvanicErrors.sploot)); + return recNetResultResponse(c, HTTPStatus.OK, false, "Steam profile could not be fetched"); const cachedlogins = await Server.Platforms.getCachedLogins(form.platform, form.platformId, true); if (cachedlogins.length == 0) { const profile = await Server.Profiles.create(form.platform, form.platformId, steam[0].realname ?? steam[0].personaname); - if (!profile) return c.json(galvanicError(GalvanicErrors.sploot)); + if (!profile) return recNetResultResponse(c, HTTPStatus.OK, false, "Account could not be created"); Server.Content.steamAvatarDownloadForProfile(profile, steam[0].avatarfull); @@ -60,7 +63,7 @@ route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form' } else { const profile = await Server.Profiles.create(form.platform, form.platformId); - if (!profile) return c.json(galvanicError(GalvanicErrors.sploot)); + if (!profile) return recNetResultResponse(c, HTTPStatus.OK, false, "Account could not be created"); return c.json({ success: true, @@ -68,14 +71,11 @@ route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form' }); } - } else return c.json(recNetError("Not a Steam user")); + } else return recNetResultResponse(c, HTTPStatus.OK, false, "Not a Steam user"); }); -route.app.get('/me', authenticate, c => { - const profile = c.get('profile'); - return c.json(profile.selfExport()); -}); +route.app.route('/', accountRoute.app); const getAccountByIdParamSchema = z.object({ id: z.coerce.number().max(Math.pow(2, 31)) diff --git a/src/routes/accounts/routes/me/root.ts b/src/routes/accounts/routes/me/root.ts new file mode 100644 index 0000000..971458d --- /dev/null +++ b/src/routes/accounts/routes/me/root.ts @@ -0,0 +1,38 @@ +import { HTTPStatus } from "@oneday/http-status"; +import { authenticate, recNetResultResponse } from "../../../../util/api.ts"; +import { createHonoRoute } from "../../../../util/import.ts"; +import z from "zod"; +import { typedZValidator } from "../../../../util/validators.ts"; +import Server from "../../../../server/server.ts"; + +export const accountRoute = createHonoRoute("/"); + +accountRoute.app.get('/me', authenticate, c => { + const profile = c.get('profile'); + return c.json(profile.selfExport()); +}); + +const changeUsernameFormSchema = z.object({ + username: z.string().min(4).max(48).regex(/^[A-Za-z0-9._-]+$/) +}); +accountRoute.app.put('/me/username', + + authenticate, + typedZValidator('form', changeUsernameFormSchema, true, "Username must only contain letters, numbers, and any of these symbols: ._-"), + + async c => { + const newUsername = c.req.valid('form').username; + const takenProf = await Server.Profiles.getByUsername(newUsername); + if (takenProf) return recNetResultResponse(c, HTTPStatus.OK, false, "Username taken"); + else { + await c.get('profile').setUsername(newUsername); + return recNetResultResponse(c, HTTPStatus.OK, true, "Username updated successfully"); + } + } + +); + +// birthday: 1970-09-10T00:00:00.0000000Z (based on age entered in OOBE) +accountRoute.app.put('/me/birthday', authenticate, c => { + return recNetResultResponse(c, HTTPStatus.OK, true, "Stub. Birthdays are not saved in Galvanic Corrosion."); +}); \ No newline at end of file diff --git a/src/routes/api/routes/avatar.ts b/src/routes/api/routes/avatar.ts index 634b763..fccacfc 100644 --- a/src/routes/api/routes/avatar.ts +++ b/src/routes/api/routes/avatar.ts @@ -1,6 +1,7 @@ +import { HTTPStatus } from "@oneday/http-status"; import { profileAvatarSchema } from "../../../server/profiles/content/Avatar.ts"; import Server from "../../../server/server.ts"; -import { authenticate } from "../../../util/api.ts"; +import { authenticate, statusResponse } from "../../../util/api.ts"; import { createHonoRoute } from "../../../util/import.ts"; import { typedZValidator } from "../../../util/validators.ts"; @@ -23,7 +24,7 @@ route.app.get('/v2', async c => { route.app.post('/v2/set', typedZValidator('json', profileAvatarSchema), async c => { const outfit = c.req.valid('json'); await c.get('profile').Avatar.setAvatar(outfit); - return c.status(200); + return statusResponse(c, HTTPStatus.OK); }); route.app.get('/v3/saved', c => { diff --git a/src/routes/api/routes/config.ts b/src/routes/api/routes/config.ts index 36a8f76..4fa4148 100644 --- a/src/routes/api/routes/config.ts +++ b/src/routes/api/routes/config.ts @@ -1,5 +1,7 @@ +import z from "zod"; import Server from "../../../server/server.ts"; import { createHonoRoute } from "../../../util/import.ts"; +import { typedZValidator } from "../../../util/validators.ts"; export const route = createHonoRoute("/config"); @@ -7,6 +9,23 @@ route.app.get('/v1/amplitude', c => { return c.json({AmplitudeKey: ""}); }); +function createNuxConfig(n: number) { + return { + Version: 0, + ButtonNumber: n, + Override: 0, + DefaultRoomName: "DormRoom", + DefaultTitle: "Dorm Room" + } +} +// i don't know what id is right now. it is an arbitrary number given by the game. +const cohortNuxIdParamSchema = z.object({ + id: z.coerce.number().nonnegative().max(32) +}); +route.app.get('/v1/cohortnux/:id', typedZValidator('param', cohortNuxIdParamSchema), c => { + return c.json([0, 1, 2, 3].map(n => createNuxConfig(n))); +}); + route.app.get('/v2', c => { return c.json(Server.getPublicConfig()); }); \ No newline at end of file diff --git a/src/routes/api/routes/rooms.ts b/src/routes/api/routes/rooms.ts index 8494bab..dc836f4 100644 --- a/src/routes/api/routes/rooms.ts +++ b/src/routes/api/routes/rooms.ts @@ -33,4 +33,10 @@ route.app.get('/v2/myrooms', authenticate, async c => { const rooms = exs.map(ex => ex.Room); return c.json(rooms); +}); + +route.app.get('/v1/hot', authenticate, async c => { + const agRoomIds = Server.Rooms.getAgRoomIds(); + const factories = await Server.Rooms.getMany(...agRoomIds.values().toArray()); + return c.json((await Promise.all(factories.map(factory => factory.export()))).map(roomDetails => roomDetails.Room)); }); \ No newline at end of file diff --git a/src/routes/auth/routes/account.ts b/src/routes/auth/routes/account.ts new file mode 100644 index 0000000..abdde7f --- /dev/null +++ b/src/routes/auth/routes/account.ts @@ -0,0 +1,32 @@ +import z from "zod"; +import { createHonoRoute } from "../../../util/import.ts"; +import { authenticate, recNetResultResponse } from "../../../util/api.ts"; +import { typedZValidator } from "../../../util/validators.ts"; +import { HTTPStatus } from "@oneday/http-status"; + +export const route = createHonoRoute('/account'); + +const changePasswordFormSchema = z.object({ + /** + * Password requirements: + * - Lowercase character + * - Uppercase character + * - A digit + * - A special character + * - 8 characters minimum + */ + newPassword: z.string().max(64).regex(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/) +}); +route.app.post('/me/changepassword', + + authenticate, + typedZValidator('form', changePasswordFormSchema, true, + "Password must contain a lowercase and uppercase character, a digit, a special character, and be at least 8 characters in length" + ), + + async c => { + await c.get('profile').setPassword(c.req.valid('form').newPassword); + return recNetResultResponse(c, HTTPStatus.OK, true, "Password updated successfully"); + } + +); \ No newline at end of file diff --git a/src/routes/auth/routes/connect.ts b/src/routes/auth/routes/connect.ts index 522b76d..440bed1 100644 --- a/src/routes/auth/routes/connect.ts +++ b/src/routes/auth/routes/connect.ts @@ -22,7 +22,7 @@ const authBodyBaseSchema = z.object({ }), platform_id: z.string().min(4), device_id: z.string().min(4), - device_class: z.string().transform(transformCheckEnum(DeviceClass)), + device_class: z.coerce.number().transform(transformCheckEnum(DeviceClass)), time: z.coerce.date(), ver: z.literal(gameVerString), asid: z.coerce.number(), @@ -97,12 +97,9 @@ route.app.post('/token', typedZValidator('form', tokenGrantSchema), async c => { const profile = await Server.Profiles.get(token.sub); if (!profile) return error(TokenRequestError.AccessDenied); - const accessToken = await Server.Platforms.getToken(profile, TokenType.Access); - const refreshToken = await Server.Platforms.getToken(profile, TokenType.Refresh); return c.json({ - access_token: accessToken, - refresh_token: refreshToken, + token: await Server.Platforms.getToken(profile, TokenType.Access), }); } catch (err) { log.w(`Authentication error (token req): ${(err as Error).stack}`); diff --git a/src/server/presence/base.ts b/src/server/presence/base.ts index 2e84e28..15d749c 100644 --- a/src/server/presence/base.ts +++ b/src/server/presence/base.ts @@ -8,6 +8,8 @@ import Command from "../commands/command.ts"; import z from "zod"; import { PushNotificationId } from "../socket/signalr/types.ts"; +const log = new Logging("Presence"); + export enum VRMovementMode { TELEPORT, WALK @@ -87,6 +89,8 @@ export class Presence { } export() { + log.d(`profId ${this.#profile.getId()} presence exported. has instance: ${this.#profile.getInstance() !== null}`); + this.#lastExported = new Date(); const e: PresenceExport = { playerId: this.#profile.getId(), @@ -127,7 +131,6 @@ export class ServerPresenceBase extends ServerContentBase { this.#intervalId = setInterval(() => { if (this.#presenceMap.size === 0) return; - this.#log.i('Clearing dead presences'); this.#deleteDeadPresences(); }, 300_000); diff --git a/src/server/profiles/manager.ts b/src/server/profiles/manager.ts index ec5e8c8..0caa3a0 100644 --- a/src/server/profiles/manager.ts +++ b/src/server/profiles/manager.ts @@ -4,7 +4,7 @@ import Profile from "./profile.ts"; import { SelfAccount, type RecNetAccount } from "./types/profile.ts"; import Command from "./../commands/command.ts"; import z from "zod"; -import { PlatformMask, PlatformType, ProfileRole, TokenType } from "../platforms/types.ts"; +import { PlatformMask, ProfileRole, TokenType } from "../platforms/types.ts"; import Logging from "@proxnet/undead-logging"; const profiles: Map = new Map(); @@ -36,29 +36,40 @@ class ProfileManagerBase extends ServerContentBase { if (await this.getByUsername(username)) username = await this.#getUnusedUsername(); return username; } - async #getUsernameDefault(username: string) { + async #getUsernameDefault(username?: string) { + if (!username) return await this.#getUnusedUsername(); + const prof = await this.getByUsername(username); if (!prof) return username; + else return await this.#getUnusedUsername(); } - async create(platform: PlatformType, platformId: string, username?: string) { - const id = await this.#getUnusedId(); - const newUsername = username ? await this.#getUsernameDefault(username) : await this.#getUnusedUsername(); + async create(platform: number, platformId: string, username?: string, id?: number) { + if (typeof id == 'number') { + const prof = await this.get(id); + if (prof) throw new Error("ID is in use"); + } + + const newId = typeof id == 'number' ? id : await this.#getUnusedId(); + const newUsername = await this.#getUsernameDefault(username); + + this.#log.d(`Creating account "${newUsername}" (${newId})`); + const newProfile: RecNetAccount = { - accountId: id, + accountId: newId, username: newUsername, displayName: newUsername, platforms: PlatformMask.None, profileImage: "DefaultProfileImage.png", createdAt: new Date() } - await this.kv.getKv().set([ ProfileManagerBase.profilesKey, id ], newProfile); - await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newUsername ], id); + await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newId ], newProfile); + await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newUsername ], newId); - await this.server.Platforms.addCachedLogin(platform, platformId, id); + await this.server.Platforms.addCachedLogin(platform, platformId, newId); - return this.get(id); + return this.get(newId); } async get(id: number) { diff --git a/src/server/profiles/profile.ts b/src/server/profiles/profile.ts index c02d23b..ac63cb1 100644 --- a/src/server/profiles/profile.ts +++ b/src/server/profiles/profile.ts @@ -14,6 +14,7 @@ import { ProfileSettingsManager } from "./content/Settings.ts"; import { ProfileSubscriptionsManager } from "./content/Subscriptions.ts"; import ProfileManagerBase from "./manager.ts"; import { recNetAccountSchema, SelfAccount, type RecNetAccount } from "./types/profile.ts"; +import { hash, verify } from "@felix/bcrypt"; class Profile { @@ -95,7 +96,7 @@ class Profile { async getRole(): Promise { const val = (await this.#kv.getKv().get(this.constructProfilePropertyKey('role'))).value; - if (!val) return ProfileRole.Game; + if (!val) return "gameClient"; else return val; } @@ -146,8 +147,8 @@ class Profile { if (this.#instance) this.#instance.removePlayer(this); inst.addPlayer(this); - this.#server.emit('presence.update', { profile: this, presence: this.#server.Presence.getPresence(this) }); this.#instance = inst; + this.#server.emit('presence.update', { profile: this, presence: this.#server.Presence.getPresence(this) }); } getId() { @@ -176,6 +177,15 @@ class Profile { return profile.getId() == this.getId(); } + async setPassword(pass: string) { + await this.#kv.getKv().set(this.constructProfilePropertyKey("password"), await hash(pass)); + } + async verifyPassword(pass: string) { + const hash = await this.#kv.getKv().get(this.constructProfilePropertyKey("password")); + if (hash.value == null) return false; + else return await verify(pass, hash.value); + } + } export default Profile; \ No newline at end of file diff --git a/src/util/api.ts b/src/util/api.ts index 3c98e6c..3ce527d 100644 --- a/src/util/api.ts +++ b/src/util/api.ts @@ -54,15 +54,11 @@ export async function authenticate(c: Context, nxt: Next) { } else return c.json(genericResponse(false, "Authorization required"), 401); } -export enum GalvanicErrors { - jex = "jex", // Error in account creation, check platform - sploot = "sploot", // Error in account creation, steamid was not valid or profile could not be created +export function recNetResult(success: boolean, error?: string) { + return {success, error}; } -export function galvanicError(code: GalvanicErrors) { - return {success: false, error:`Galvanic Error (code: ${code})`}; -} -export function recNetError(err: string) { - return {success:false, error: err}; +export function recNetResultResponse(c: Context, status: HTTPStatus, success: boolean, error?: string) { + return c.json(recNetResult(success, error), status as ContentfulStatusCode); } export function generateRandomString(length: number) { @@ -126,7 +122,7 @@ export class RateLimiter { this.#addressIncrement(address); const hits = this.#getAddressHits(address); - if (hits && hits > this.#hitLimit) return c.json(recNetError("Rate Limited. Please try again later."), 429); + if (hits && hits > this.#hitLimit) return c.json(recNetResult(false, "Rate Limited. Please try again later."), 429); else return next(); }; } diff --git a/src/util/flags.ts b/src/util/flags.ts new file mode 100644 index 0000000..fbd3c15 --- /dev/null +++ b/src/util/flags.ts @@ -0,0 +1,46 @@ +/* + ChatGPT GPT-5 prompt: + + Create a TypeScript class that can handle operations on a number + similar to how the [Flags] attribute changes enums in C#. + Include methods for checking the presence of a certain bit, adding a bit if it is not included, and removing bits. + + Make the class generic. +*/ +export class BitFlags { + private value: T; + + constructor(initialValue: T | number = 0 as T) { + this.value = initialValue as T; + } + + /** Get current numeric value */ + public getValue(): T { + return this.value; + } + + /** Check if a specific flag is set */ + public has(flag: T): boolean { + return (this.value & flag) === flag; + } + + /** Add a flag */ + public add(flag: T): void { + this.value = (this.value | flag) as T; + } + + /** Remove a flag */ + public remove(flag: T): void { + this.value = (this.value & ~flag) as T; + } + + /** Toggle a flag */ + public toggle(flag: T): void { + this.value = (this.value ^ flag) as T; + } + + /** Reset all flags */ + public clear(): void { + this.value = 0 as T; + } +} \ No newline at end of file diff --git a/src/util/validators.ts b/src/util/validators.ts index 19401ad..ab2141a 100644 --- a/src/util/validators.ts +++ b/src/util/validators.ts @@ -3,6 +3,7 @@ import type { MiddlewareHandler } from "@hono/hono"; import { z } from "zod"; import type { HonoEnv } from "./types.ts"; import { ZodSchema } from "zod/v4"; +import { HTTPStatus } from "@oneday/http-status"; // thanks claude, this hurt my brain! @@ -11,9 +12,16 @@ export const typedZValidator = < S extends ZodSchema >( target: T, - schema: S + schema: S, + httpOk?: boolean, + recNetError?: string ) => { - return zValidator(target, schema) as MiddlewareHandler< + return zValidator(target, schema, (result, c) => { + if (!result.success) { + if (recNetError) return c.json({ success: false, error: recNetError }, httpOk == true ? HTTPStatus.OK : HTTPStatus.BadRequest); + else return c.json({ success: false, error: "Request validation failed" }, httpOk == true ? HTTPStatus.OK : HTTPStatus.BadRequest); + } + }) as MiddlewareHandler< HonoEnv, string, {