This repository has been archived on 2026-03-19. You can view files and clone it, but cannot push or open issues or pull requests.
Files
galvanic-corrosion/src/apiutils.ts
zombieb 492129df17 * Unified profiles, rather than instantiating profiles every time we want to access one
* Socket and live instance changes
* Possible problem with Deno's handling of sockets, compatibility issue with Node?
2025-03-27 20:00:17 -04:00

300 lines
8.3 KiB
TypeScript

// @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 { IncomingMessage } from "node:http";
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 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));
}
};
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;
Message: string;
};
export function RecNetResponse(success: boolean, message: string) {
const msg: RecNetResponse = { Success: success, Message: 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 getSrcIpDefaultRaw(rq: IncomingMessage) {
const cfIp = rq.headers['cf-connecting-ip'];
if (cfIp) return cfIp;
const xrIp = rq.headers['x-real-ip'];
if (xrIp) return xrIp;
return rq.socket.remoteAddress ? rq.socket.remoteAddress : "(unknown source)";
}
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, limit: number) {
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);
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.securepublichost ? 'https' : 'http'}://${config.web.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);
nxt();
} else {
returnUnauthorized();
return;
}
} catch (err) {
returnUnauthorized();
log.w(`User Authentication failed: ${err}`);
}
}
export type NoBody = Record<string | number | symbol, never>;
export * as APIUtils from "./apiutils.ts";