- Added missing room images
- Removed internal rooms and disallow cloning some rooms
- License fixes
- Switched target to 20200306
- Socket header fixes
- Sockets are closed upon shutdown
* Sockets persist after being destroyed, fix
- Config changes for 20200306
- Settings initialized
- Name generation words cannot be longer than 9 characters
- Dorm generation changes and fixes
- Added some settings keys
- Matchmaking additions
* Instances are not yet updated to be or not to be in-progress
- Instance fixes and logging
- Image operation fixes
- DisplayName route start
- Challenge route start
- Default Amplitude key (i can see althe Amplitude requests are ignored
- Rate limiting ease
- GameConfigs properly queried and sent
- Many 'bulk' endpoints were added in or around 20200306
- Objective routes started
- DormRoom redirection in v2/name
- Client doesn't care if it gets 200 when setting prefs
- Balance/storefronts started
- Matchmaking goto/room timer and fixes
- Selfhosted Photon addition on the client sends matchmaking /photonregionpings, ignore since Photon is only one server in one region
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
/* 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/>. */
|
|
|
|
// @ts-types = "npm:@types/express"
|
|
import express from "express";
|
|
import Logging from "@proxnet/undead-logging";
|
|
import { decode } from "@gz/jwt";
|
|
import { Config } from "./config.ts";
|
|
import { AuthType, User, UserTokenFormat } from "./data/users.ts";
|
|
import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts";
|
|
import z from "zod";
|
|
import Matchmaking from "./data/live/base.ts";
|
|
|
|
const config = Config.getConfig();
|
|
|
|
const log = new Logging("APIUtils");
|
|
|
|
type AppRouter = {
|
|
path: string;
|
|
router: express.Router;
|
|
};
|
|
|
|
export function createRouter(path: string) {
|
|
const router: AppRouter = {
|
|
path: path,
|
|
router: express.Router(),
|
|
};
|
|
return router;
|
|
}
|
|
|
|
export function setCacheAllowed(_rq: express.Request, rs: express.Response, nxt: express.NextFunction) {
|
|
rs.setHeader("Cache-Control", 'public');
|
|
nxt();
|
|
}
|
|
|
|
export function generateRandomString(length: number) {
|
|
const characters =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
let randomString = "";
|
|
|
|
for (let i = 0; i < length; i++) {
|
|
const randomIndex = Math.floor(Math.random() * characters.length);
|
|
randomString += characters.charAt(randomIndex);
|
|
}
|
|
|
|
return randomString;
|
|
}
|
|
|
|
export function checkQueryTypes<T>(typeDef: T) {
|
|
return (
|
|
rq: express.Request,
|
|
rs: express.Response,
|
|
nxt: express.NextFunction,
|
|
) => {
|
|
for (const key in typeDef) {
|
|
if (typeof rq.query[key] !== typeof typeDef[key]) {
|
|
rs.statusCode = 400;
|
|
rs.json(
|
|
genericResponseFormat(
|
|
true,
|
|
"One or more query parameters were invalid or not found.",
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
nxt();
|
|
};
|
|
}
|
|
|
|
export const validateRequestBody = <T>(schema: z.ZodSchema<T>) => (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));
|
|
}
|
|
};
|
|
export const validateQuery = <T>(schema: z.ZodSchema<T>) => (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
|
|
try {
|
|
schema.parse(rq.query);
|
|
nxt();
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError)
|
|
rs.status(400).json(genericResponseFormat(true, "Bad request", undefined, error.errors));
|
|
}
|
|
};
|
|
|
|
type genericResponse = {
|
|
failure: boolean,
|
|
errors?: object, // zod only
|
|
message?: string,
|
|
data?: object
|
|
}
|
|
|
|
export function generateMask(...num: number[]) {
|
|
return num.reduce((sum, val) => sum + val, 0);
|
|
}
|
|
|
|
export function genericResponseFormat(
|
|
failure: boolean,
|
|
msg?: string,
|
|
data?: object,
|
|
errors?: object,
|
|
) {
|
|
return { failure: failure, errors: errors, message: msg, data: data } as genericResponse;
|
|
}
|
|
export function genericResponse(
|
|
failure: boolean,
|
|
msg?: string,
|
|
data?: object,
|
|
errors?: z.ZodError[],
|
|
) {
|
|
return (_rq: express.Request, rs: express.Response<genericResponse>) => {
|
|
rs.json({ failure: failure, errors: errors, message: msg, data: data });
|
|
};
|
|
}
|
|
type RecNetResponse = {
|
|
success: boolean;
|
|
error?: string;
|
|
};
|
|
export function RecNetResponse(success: boolean, message?: string) {
|
|
const msg: RecNetResponse = { success: success, error: message };
|
|
return (_rq: express.Request, rs: express.Response) => {
|
|
rs.json(msg);
|
|
};
|
|
}
|
|
|
|
export function logBody(
|
|
rq: express.Request,
|
|
_rs: express.Response,
|
|
nxt: express.NextFunction,
|
|
) {
|
|
log.d(`Request body: ${JSON.stringify(rq.body)}`);
|
|
nxt();
|
|
}
|
|
|
|
export function emptyArrayResponse(_rq: express.Request, rs: express.Response) {
|
|
rs.json([]);
|
|
}
|
|
|
|
export function getSrcIpDefault(rq: express.Request): string {
|
|
const cfIp = rq.header("cf-connecting-ip");
|
|
if (cfIp !== undefined) return cfIp;
|
|
|
|
const xrIp = rq.header("x-real-ip");
|
|
if (xrIp !== undefined) return xrIp;
|
|
|
|
return typeof rq.ip === "undefined" ? "(unknown source)" : rq.ip;
|
|
}
|
|
export function getSrcIpDefaultDeno(req: Request, info: Deno.ServeHandlerInfo<Deno.NetAddr>) {
|
|
const cfIp = req.headers.get('cf-connecting-ip');
|
|
if (cfIp) return cfIp;
|
|
|
|
const xrIp = req.headers.get('x-real-ip');
|
|
if (xrIp) return xrIp;
|
|
|
|
return info.remoteAddr.hostname;
|
|
}
|
|
|
|
export function statusResponse(code: number) {
|
|
return (_rq: express.Request, rs: express.Response) => {
|
|
rs.sendStatus(code);
|
|
};
|
|
}
|
|
|
|
export class RateLimiter {
|
|
#intervalId: number;
|
|
|
|
#hitLimit: number;
|
|
|
|
#addressHits: Map<string, number> = new Map();
|
|
|
|
/**
|
|
* @param interval In seconds: rate at which hit counts will be cleared
|
|
* @param limit Number of hits (inclusive) before requests are blocked
|
|
*/
|
|
constructor(interval: number = 20, limit: number = 2) {
|
|
this.#hitLimit = limit;
|
|
|
|
this.#intervalId = setInterval(() => {
|
|
this.#addressHits.clear();
|
|
}, interval * 1000);
|
|
|
|
Deno.addSignalListener("SIGINT", () => {
|
|
this.#close();
|
|
});
|
|
}
|
|
|
|
#addressIncrement(address: string) {
|
|
const hits = this.#addressHits.get(address);
|
|
if (hits) this.#addressHits.set(address, hits + 1);
|
|
else this.#addressHits.set(address, 1);
|
|
}
|
|
|
|
#getAddressHits(address: string) {
|
|
const hits = this.#addressHits.get(address);
|
|
if (hits) return hits;
|
|
else {
|
|
this.#addressHits.set(address, 1);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
middle() {
|
|
return (
|
|
rq: express.Request,
|
|
rs: express.Response,
|
|
nxt: express.NextFunction,
|
|
) => {
|
|
const address = getSrcIpDefault(rq);
|
|
if (address == '127.0.0.1' || address == '::1') {
|
|
nxt();
|
|
return;
|
|
}
|
|
|
|
this.#addressIncrement(address);
|
|
|
|
const hits = this.#getAddressHits(address);
|
|
if (hits && hits > this.#hitLimit) {
|
|
rs.statusCode = 429;
|
|
rs.json(
|
|
genericResponseFormat(
|
|
true,
|
|
`Rate limit for address ${address} reached. Try again in a moment.`,
|
|
),
|
|
);
|
|
return;
|
|
} else nxt();
|
|
};
|
|
}
|
|
|
|
#close() {
|
|
clearInterval(this.#intervalId);
|
|
}
|
|
}
|
|
|
|
export interface TokenBaseFormat {
|
|
typ: AuthType;
|
|
iss: string;
|
|
exp: number;
|
|
}
|
|
export type TokenFormat = UserTokenFormat | ProfileTokenFormat;
|
|
|
|
const TokenBaseSchema = z.object({
|
|
typ: z.nativeEnum(AuthType),
|
|
iss: z.string().url(),
|
|
exp: z.number()
|
|
});
|
|
export const UserTokenSchema = TokenBaseSchema.extend({
|
|
sub: z.string(),
|
|
typ: z.literal(AuthType.Web)
|
|
});
|
|
export const ProfileTokenSchema = TokenBaseSchema.extend({
|
|
sub: z.number(),
|
|
typ: z.literal(AuthType.Game)
|
|
});
|
|
export const TokenSchema = z.discriminatedUnion('typ', [
|
|
UserTokenSchema,
|
|
ProfileTokenSchema
|
|
]);
|
|
|
|
export async function Authentication(
|
|
rq: express.Request,
|
|
rs: express.Response,
|
|
nxt: express.NextFunction,
|
|
) {
|
|
function returnUnauthorized() {
|
|
rs.statusCode = 401;
|
|
rs.json(genericResponseFormat(true, "Authorization required."));
|
|
}
|
|
|
|
const userToken: string | undefined = rq.header("GalvanicAuth");
|
|
const profileToken: string | undefined = rq.header("Authorization");
|
|
let token: string;
|
|
if (typeof userToken == "undefined" && typeof profileToken == "undefined") {
|
|
returnUnauthorized();
|
|
return;
|
|
} else if (typeof userToken == 'string') token = userToken;
|
|
else if (typeof profileToken == 'string') {
|
|
const splitToken = profileToken.split(' ');
|
|
if (splitToken.length >= 2) token = splitToken[1];
|
|
else {
|
|
returnUnauthorized();
|
|
return;
|
|
}
|
|
} else {
|
|
returnUnauthorized();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const decodedToken = await decode<TokenFormat>(token, config.auth.secret, {algorithm: "HS512"});
|
|
const schemaResult = TokenSchema.safeParse(decodedToken);
|
|
if (!schemaResult.success) {
|
|
returnUnauthorized();
|
|
return;
|
|
}
|
|
|
|
const valid = ![ // used to contain more conditions, now is only 1
|
|
decodedToken.iss == `${config.web.api.securepublichost ? 'https' : 'http'}://${config.web.api.publichost}`,
|
|
].includes(false);
|
|
if (valid) {
|
|
if (decodedToken.typ == AuthType.Web) rs.locals.user = new User(decodedToken.sub);
|
|
else if (decodedToken.typ == AuthType.Game) rs.locals.profile = UnifiedProfile.get(decodedToken.sub);
|
|
rs.locals.token = token;
|
|
|
|
nxt();
|
|
} else {
|
|
returnUnauthorized();
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
returnUnauthorized();
|
|
log.w(`User Authentication failed: ${err}`);
|
|
}
|
|
}
|
|
|
|
export function AuthenticationType(type: AuthType) {
|
|
return (_rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
|
|
const profile = rs.locals.profile;
|
|
const user = rs.locals.user;
|
|
|
|
if ((type == AuthType.Game && !profile) || (type == AuthType.Web && !user)) {
|
|
rs.json(genericResponseFormat(true, 'Wrong authentication type provided.'));
|
|
return;
|
|
} else nxt();
|
|
}
|
|
}
|
|
|
|
export function LoginLock(rq: express.Request<NoBody, NoBody, { LoginLock: string }>, rs: express.Response, nxt: express.NextFunction) {
|
|
//log.d(`LoginLock for ${rs.locals.profile.getId()}: ${rq.body.LoginLock}`);
|
|
const matches = Matchmaking.lockMatches(rs.locals.profile, rq.body.LoginLock);
|
|
if (matches == null) {
|
|
rs.json(genericResponseFormat(true, "Login Lock failure"));
|
|
return;
|
|
} else {
|
|
if (matches) nxt();
|
|
else {
|
|
rs.json(genericResponseFormat(true, "Login Lock failure"));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
export type NoBody = Record<string | number | symbol, never>;
|
|
|
|
export function startTimer(_rq: express.Request, rs: express.Response, nxt: express.NextFunction) {
|
|
rs.locals.timer = performance.now();
|
|
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`);
|
|
}
|
|
|
|
export * as APIUtils from "./apiutils.ts";
|