diff --git a/deno.json b/deno.json index efa0165..46bef5d 100644 --- a/deno.json +++ b/deno.json @@ -17,7 +17,7 @@ "express": "npm:express@^4.21.2", "ioredis": "npm:ioredis@^5.5.0", "validator": "npm:validator@^13.12.0", - "bcrypt": "https://deno.land/x/bcrypt@v0.3.0/mod.ts" + "zod": "npm:zod@^3.24.2" }, "files": [], "compilerOptions": { diff --git a/deno.lock b/deno.lock index 248013e..d2763de 100644 --- a/deno.lock +++ b/deno.lock @@ -20,7 +20,8 @@ "npm:discord.js@^14.16.3": "14.16.3", "npm:express@^4.21.2": "4.21.2", "npm:ioredis@^5.5.0": "5.5.0", - "npm:validator@^13.12.0": "13.12.0" + "npm:validator@^13.12.0": "13.12.0", + "npm:zod@^3.24.2": "3.24.2" }, "jsr": { "@gz/jwt@0.1.0": { @@ -710,6 +711,9 @@ }, "ws@8.18.0": { "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" + }, + "zod@3.24.2": { + "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==" } }, "redirects": { @@ -865,7 +869,8 @@ "npm:discord.js@^14.16.3", "npm:express@^4.21.2", "npm:ioredis@^5.5.0", - "npm:validator@^13.12.0" + "npm:validator@^13.12.0", + "npm:zod@^3.24.2" ] } } diff --git a/src/apiutils.ts b/src/apiutils.ts index 4d2560a..7f17ff3 100644 --- a/src/apiutils.ts +++ b/src/apiutils.ts @@ -5,6 +5,7 @@ import { decode } from "@gz/jwt"; import { Config } from "./config.ts"; import { AuthType, User, UserTokenFormat } from "./data/users.ts"; import Profile, { ProfileTokenFormat } from "./data/profiles.ts"; +import z from "zod"; const config = Config.getConfig(); @@ -57,43 +58,40 @@ export function checkQueryTypes(typeDef: T) { nxt(); }; } -export function checkBodyTypes(typeDef: T) { - return ( - rq: express.Request, - rs: express.Response, - nxt: express.NextFunction, - ) => { - for (const key in typeDef) { - if (typeof rq.body[key] !== typeof typeDef[key]) { - log.e(`Body check for key '${key}' failed.`); - rs.statusCode = 400; - rs.json( - genericResponseFormat( - true, - "One or more body values were invalid or not found.", - ), - ); - return; - } - } + +export const validateRequestBody = (schema: z.ZodSchema) => (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => { + try { + schema.parse(rq.body); nxt(); - }; + } catch (error) { + if (error instanceof z.ZodError) + rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors)); + } +}; + +type genericResponse = { + failure: boolean, + errors?: object, // zod only + message?: string, + data?: object } export function genericResponseFormat( failure: boolean, - msg: string | null = null, - data: object | null = null, + msg?: string, + data?: object, + errors?: object, ) { - return { failed: failure, message: msg, data: data }; + return { failure: failure, errors: errors, message: msg, data: data } as genericResponse; } export function genericResponse( failure: boolean, - msg: string | null = null, - data: object | null = null, + msg?: string, + data?: object, + errors?: z.ZodError[], ) { - return (_rq: express.Request, rs: express.Response) => { - rs.json({ failed: failure, message: msg, data: data }); + return (_rq: express.Request, rs: express.Response) => { + rs.json({ failure: failure, errors: errors, message: msg, data: data }); }; } type RecNetResponse = { diff --git a/src/data/content/rooms.ts b/src/data/content/rooms.ts new file mode 100644 index 0000000..088c07a --- /dev/null +++ b/src/data/content/rooms.ts @@ -0,0 +1,8 @@ +class RoomsBase { + + + +} + +const Rooms = new RoomsBase(); +export default Rooms; \ No newline at end of file diff --git a/src/data/content/roomtypes.ts b/src/data/content/roomtypes.ts new file mode 100644 index 0000000..a72b149 --- /dev/null +++ b/src/data/content/roomtypes.ts @@ -0,0 +1,123 @@ +export enum IntegratedRoomScene { + Calibration = "f5fbd9c9-e853-4036-9d48-5f68e861af04", + DormRoom = "76d98498-60a1-430c-ab76-b54a29b7a163", + RecCenter = "cbad71af-0831-44d8-b8ef-69edafa841f6", + ThreeDCharades = "4078dfed-24bb-4db7-863f-578ba48d726b", + DiscGolfLake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + DiscGolfPropulsion = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + Dodgeball = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + Paddleball = "d89f74fa-d51e-477a-a425-025a891dd499", + Paintball_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + Paintball_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", + Paintball_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", + Paintball_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", + Paintball_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", + PaintballVR_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + PaintballVR_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", + PaintballVR_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", + PaintballVR_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", + PaintballVR_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", + GoldenTrophy = "91e16e35-f48f-4700-ab8a-a1b79e50e51b", + TheRiseofJumbotron = "acc06e66-c2d0-4361-b0cd-46246a4c455c", + CrimsonCauldron = "949fa41f-4347-45c0-b7ac-489129174045", + IsleOfLostSkulls = "7e01cfe0-820a-406f-b1b3-0a5bf575235c", + Soccer = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + LaserTagHangar = "239e676c-f12f-489f-bf3a-d4c383d692c3", + LaserTagCyberJunk = "9d6456ce-6264-48b4-808d-2d96b3d91038", + RecRoyaleSquads = "253fa009-6e65-4c90-91a1-7137a56a267f", + RecRoyaleVR = "253fa009-6e65-4c90-91a1-7137a56a267f", + RecRoyaleSolos = "b010171f-4875-4e89-baba-61e878cd41e1", + Lounge = "a067557f-ca32-43e6-b6e5-daaec60b4f5a", + PerformanceHall = "9932f88f-3929-43a0-a012-a40b5128e346", + MakerRoom = "a75f7547-79eb-47c6-8986-6767abcb4f92", + Park = "0a864c86-5a71-4e18-8041-8124e4dc9d98", + ArtTesting = "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79", + River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", + Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", + Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", + Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", + Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", + Lake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0", + PropulsionTestRange = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", + Gym = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", + Stadium = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", + Hangar = "239e676c-f12f-489f-bf3a-d4c383d692c3", + CyberJunkCity = "9d6456ce-6264-48b4-808d-2d96b3d91038", + DodgeballVR = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", +} + +export enum RoomState { + Active, + PendingJunior = 11, + Moderation_PendingReview = 100, + Moderation_Closed, + MarkedForDelete = 1000 +} + +export enum RoomAccessibility { + Private, + Public, + Unlisted +} + +export interface Room { + RoomId: number, + Name: string, + Description: string, + CreatorPlayerId: number, + ImageName: string, + State: RoomState, + Accessibility: RoomAccessibility, + SupportsLevelVoting: boolean, + IsAGRoom: boolean, + IsDormRoom?: boolean, + CloningAllowed: boolean, + SupportsVRLow?: boolean, + SupportsMobile?: boolean, + SupportsScreens: boolean, + SupportsWalkVR: boolean, + SupportsTeleportVR: boolean, + AllowsJuniors: boolean, + RoomWarningMask: number, // generated by dedicated mask generation function + CustomRoomWarning: string, + DisableMicAutoMute?: boolean +} + +export enum TagType { + General, + Auto, + AGOnly, + Banned +} + +export interface TagDTO { + Tag: string, + Type: TagType +} + +export interface RoomScene { + RoomSceneId: number, + RoomId: number, + RoomSceneLocationId: IntegratedRoomScene, + Name: string, + IsSandbox: boolean, + DataBlobName: string, + MaxPlayers: number, + CanMatchmakeInto?: boolean, + DataModifiedAt: string +} + +export interface RoomDetails { + Room: Room, + Scenes: RoomScene, + CoOwners: number[], + InvitedCoOwners: number[], + Moderators?: number[], + InvitedModerators?: number[], + Hosts: number[], + InvitedHosts: number[], + CheerCount: number, + FavoriteCount: number, + VisitCount: number, + Tags: TagDTO[] +} \ No newline at end of file diff --git a/src/data/live/instances.ts b/src/data/live/instances.ts index 3b5ce48..28caf61 100644 --- a/src/data/live/instances.ts +++ b/src/data/live/instances.ts @@ -1,56 +1,9 @@ import Logging from "@proxnet/undead-logging"; import Profile from "../profiles.ts"; +import { IntegratedRoomScene } from "../content/roomtypes.ts"; const log = new Logging("Instances"); -enum IntegratedRoomScene { - Calibration = "f5fbd9c9-e853-4036-9d48-5f68e861af04", - DormRoom = "76d98498-60a1-430c-ab76-b54a29b7a163", - RecCenter = "cbad71af-0831-44d8-b8ef-69edafa841f6", - ThreeDCharades = "4078dfed-24bb-4db7-863f-578ba48d726b", - DiscGolfLake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0", - DiscGolfPropulsion = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", - Dodgeball = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", - Paddleball = "d89f74fa-d51e-477a-a425-025a891dd499", - Paintball_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", - Paintball_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", - Paintball_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", - Paintball_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", - Paintball_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", - PaintballVR_River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", - PaintballVR_Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", - PaintballVR_Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", - PaintballVR_Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", - PaintballVR_Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", - GoldenTrophy = "91e16e35-f48f-4700-ab8a-a1b79e50e51b", - TheRiseofJumbotron = "acc06e66-c2d0-4361-b0cd-46246a4c455c", - CrimsonCauldron = "949fa41f-4347-45c0-b7ac-489129174045", - IsleOfLostSkulls = "7e01cfe0-820a-406f-b1b3-0a5bf575235c", - Soccer = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", - LaserTagHangar = "239e676c-f12f-489f-bf3a-d4c383d692c3", - LaserTagCyberJunk = "9d6456ce-6264-48b4-808d-2d96b3d91038", - RecRoyaleSquads = "253fa009-6e65-4c90-91a1-7137a56a267f", - RecRoyaleVR = "253fa009-6e65-4c90-91a1-7137a56a267f", - RecRoyaleSolos = "b010171f-4875-4e89-baba-61e878cd41e1", - Lounge = "a067557f-ca32-43e6-b6e5-daaec60b4f5a", - PerformanceHall = "9932f88f-3929-43a0-a012-a40b5128e346", - MakerRoom = "a75f7547-79eb-47c6-8986-6767abcb4f92", - Park = "0a864c86-5a71-4e18-8041-8124e4dc9d98", - ArtTesting = "42699ed2-0c1b-4f3d-93a2-ce01dfce7a79", - River = "e122fe98-e7db-49e8-a1b1-105424b6e1f0", - Homestead = "a785267d-c579-42ea-be43-fec1992d1ca7", - Quarry = "ff4c6427-7079-4f59-b22a-69b089420827", - Clearcut = "380d18b5-de9c-49f3-80f7-f4a95c1de161", - Spillway = "58763055-2dfb-4814-80b8-16fac5c85709", - Lake = "f6f7256c-e438-4299-b99e-d20bef8cf7e0", - PropulsionTestRange = "d9378c9f-80bc-46fb-ad1e-1bed8a674f55", - Gym = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", - Stadium = "6d5eea4b-f069-4ed0-9916-0e2f07df0d03", - Hangar = "239e676c-f12f-489f-bf3a-d4c383d692c3", - CyberJunkCity = "9d6456ce-6264-48b4-808d-2d96b3d91038", - DodgeballVR = "3d474b26-26f7-45e9-9a36-9b02847d5e6f", -} - export interface RoomInstance { roomInstanceId: number, diff --git a/src/data/live/presence.ts b/src/data/live/presence.ts index 6cb83e3..b91a54a 100644 --- a/src/data/live/presence.ts +++ b/src/data/live/presence.ts @@ -1,3 +1,5 @@ + + class PresenceBase { diff --git a/src/main.ts b/src/main.ts index d902621..4326055 100644 --- a/src/main.ts +++ b/src/main.ts @@ -76,6 +76,7 @@ const userRouter = await import("./routes/user.ts"); const authRouter = await import("./routes/auth.ts"); const accountRouter = await import("./routes/account.ts"); const imgRouter = await import("./routes/img.ts"); +const matchRouter = await import("./routes/match.ts"); app.use(nameserverRouter.route.path, nameserverRouter.route.router); app.use(apiRouter.route.path, apiRouter.route.router); @@ -83,6 +84,7 @@ app.use(userRouter.route.path, userRouter.route.router); app.use(authRouter.route.path, authRouter.route.router); app.use(accountRouter.route.path, accountRouter.route.router); app.use(imgRouter.route.path, imgRouter.route.router); +app.use(matchRouter.route.path, matchRouter.route.router); app.use((rq: express.Request, rs: express.Response) => { log.e( diff --git a/src/routes/account/account.ts b/src/routes/account/account.ts index 3367557..fb7dd63 100644 --- a/src/routes/account/account.ts +++ b/src/routes/account/account.ts @@ -1,6 +1,7 @@ import { APIUtils } from "../../apiutils.ts"; import express from "express"; import Profile from "../../data/profiles.ts"; +import { z } from "zod"; export const route = APIUtils.createRouter("/account"); @@ -10,6 +11,12 @@ interface CreateAccountRequestBody { deviceId: string; } +const CreateAccountRequestBodySchema = z.object({ + platform: z.string(), + platformId: z.string(), + deviceId: z.string() +}); + const rateLimit = new APIUtils.RateLimiter(25, 5); route.router.post("/create", @@ -17,11 +24,7 @@ route.router.post("/create", rateLimit.middle(), APIUtils.Authentication, express.urlencoded({ extended: true }), - APIUtils.checkBodyTypes({ - platform: "", - platformId: "", - deviceId: "", - }), + APIUtils.validateRequestBody(CreateAccountRequestBodySchema), async (_rq, rs) => { const newAcc = await Profile.init(); @@ -33,6 +36,7 @@ route.router.post("/create", value: await newAcc.export(), }); }, + ); route.router.get("/bulk", diff --git a/src/routes/api/PlayerReporting.ts b/src/routes/api/PlayerReporting.ts index 8b8772e..2695033 100644 --- a/src/routes/api/PlayerReporting.ts +++ b/src/routes/api/PlayerReporting.ts @@ -1,6 +1,7 @@ import { APIUtils, NoBody } from "../../apiutils.ts"; import express from "express"; import Logging from "@proxnet/undead-logging"; +import { z } from "zod"; const log = new Logging("PlayerReportingRoute"); @@ -10,11 +11,15 @@ interface HileMessage { Message: string; } +const HileMessageSchema = z.object({ + Message: z.string() +}); + route.router.post('/v1/hile', APIUtils.Authentication, express.json(), - APIUtils.checkBodyTypes({Message: ""}), + APIUtils.validateRequestBody(HileMessageSchema), (rq: express.Request, rs) => { rs.sendStatus(204); diff --git a/src/routes/auth/connect.ts b/src/routes/auth/connect.ts index 7bdee26..9d35c91 100644 --- a/src/routes/auth/connect.ts +++ b/src/routes/auth/connect.ts @@ -4,6 +4,7 @@ import Profile from "../../data/profiles.ts"; import { decode } from "@gz/jwt"; import { Config } from "../../config.ts"; import Logging from "@proxnet/undead-logging"; +import { z } from "zod"; const config = Config.getConfig(); @@ -35,6 +36,35 @@ interface RefreshRequest extends AuthBodyBase { type TokenRequestBody = TokenRequest | RefreshRequest; +const AuthBodyBaseSchema = z.object({ + grant_type: z.string(), + client_id: z.string(), + client_secret: z.string(), + platform: z.string(), + platform_id: z.string(), + device_id: z.string(), + device_class: z.string(), + time: z.string(), + ver: z.string(), + asid: z.string(), + platform_auth: z.string(), +}); + +const TokenRequestSchema = AuthBodyBaseSchema.extend({ + grant_type: z.literal('cached_login'), + account_id: z.string(), +}); + +const RefreshRequestSchema = AuthBodyBaseSchema.extend({ + grant_type: z.literal('refresh_token'), + refresh_token: z.string(), +}); + +const TokenRequestBodySchema = z.discriminatedUnion('grant_type', [ + TokenRequestSchema, + RefreshRequestSchema, +]); + interface TokenResponseBody { error?: string; error_description?: string; @@ -47,19 +77,7 @@ route.router.post("/token", APIUtils.Authentication, express.urlencoded({ extended: true }), APIUtils.logBody, - APIUtils.checkBodyTypes({ - grant_type: "", - client_id: "", - client_secret: "", - platform: "", - platform_id: "", - device_id: "", - device_class: "", - time: "", - ver: "", - asid: "", - platform_auth: "" - }), + APIUtils.validateRequestBody(TokenRequestBodySchema), async ( rq: express.Request, diff --git a/src/routes/match.ts b/src/routes/match.ts new file mode 100644 index 0000000..bade688 --- /dev/null +++ b/src/routes/match.ts @@ -0,0 +1,6 @@ +import { APIUtils } from "../apiutils.ts"; +import { route as PlayerRoute } from "./match/player.ts"; + +export const route = APIUtils.createRouter('/match'); + +route.router.use(PlayerRoute.path, PlayerRoute.router); \ No newline at end of file diff --git a/src/routes/match/player.ts b/src/routes/match/player.ts new file mode 100644 index 0000000..9f6f8c6 --- /dev/null +++ b/src/routes/match/player.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { APIUtils } from "../../apiutils.ts"; +import express from "express"; + +export const route = APIUtils.createRouter('/player'); + +interface BaseLoginLock { + LoginLock: string +} + +const LoginSchema = z.object({ + LoginLock: z.string().uuid("LoginLock must be a UUIDv4") +}); + +route.router.post('/login', + + APIUtils.Authentication, + express.urlencoded({extended: true}), + APIUtils.validateRequestBody(LoginSchema), + + (rq, rs) => { + // temporary + rs.sendStatus(200); + }, + +) \ No newline at end of file diff --git a/src/routes/user.ts b/src/routes/user.ts index fad9b7d..20af765 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -1,10 +1,12 @@ import { APIUtils, getSrcIpDefault, NoBody } from "../apiutils.ts"; // @ts-types = "npm:@types/express" import express from "express"; -import { User } from "../data/users.ts"; +import { User, UserTokenFormat } from "../data/users.ts"; import { Config } from "../config.ts"; import crypto from "node:crypto"; import Logging from "@proxnet/undead-logging"; +import { decode } from "@gz/jwt"; +import z from "zod"; const log = new Logging("UserRoute"); @@ -25,22 +27,27 @@ interface AuthRequestRoot { pubkey: string; } +const AuthRequestSecSchema = z.object({ + timestamp: z.number(), + nonce: z.string(), + server_id: z.string(), +}); + +const AuthRequestRootSchema = z.object({ + client_id: z.string(), + message: AuthRequestSecSchema, + signature: z.string(), + pubkey: z.string(), +}); + const rateLimit = new APIUtils.RateLimiter(60, 1); -route.router.post( - "/auth", +route.router.post("/auth", + rateLimit.middle(), express.json(), - APIUtils.checkBodyTypes({ - client_id: "asdf", - message: { - timestamp: 0, - nonce: "asdf", - server_id: "asdf", - }, - signature: "asdf", - pubkey: "asdf", - }), + APIUtils.validateRequestBody(AuthRequestRootSchema), + async ( rq: express.Request, rs: express.Response, @@ -101,6 +108,7 @@ route.router.post( pubkey: rq.body.pubkey, }); if (obj == null) { + log.w(`Obj null`); rs.sendStatus(500); return; } else user = obj; @@ -118,3 +126,22 @@ route.router.post( rs.json({ token: token }); }, ); + +const checkRateLimit = new APIUtils.RateLimiter(10, 3); + +route.router.get('/checkExpired', checkRateLimit.middle(), async (rq, rs) => { + + const token = rq.header('GalvanicAuth'); + if (!token) { + rs.json(true); + return; + } + + try { + const decodedToken = await decode(token, config.auth.secret, { algorithm: "HS512", leeway: 31536000 }); // 1 year leeway + rs.json(decodedToken.exp < Math.round(Date.now() / 1000)); + } catch { + rs.json(true); + } + +}); \ No newline at end of file