// @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(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 = (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 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) => { 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 getSrcIpDefaultDeno(req: Request, info: Deno.ServeHandlerInfo) { 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 = 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(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, 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; export * as APIUtils from "./apiutils.ts";