Files
galvanic-corrosion-rewrite/src/util/api.ts
2025-09-12 20:45:48 -04:00

172 lines
5.8 KiB
TypeScript

import { Context, Next } from "@hono/hono";
import { type HonoEnv } from "./types.ts";
import Logging from "@proxnet/undead-logging";
import z from "zod";
import { verify } from "@hono/hono/jwt";
import Server from "../server/server.ts";
import { TokenFormat } from "../server/platforms/types.ts";
import { HTTPStatus, httpStatusText } from "@oneday/http-status";
import { ContentfulStatusCode } from "@hono/hono/utils/http-status";
import { env } from "../env.ts";
const log = new Logging("APIUtils");
export function statusResponse(c: Context, code: HTTPStatus, msg?: string) {
return c.json(genericResponse(code < 400, msg ?? httpStatusText(code)), code as ContentfulStatusCode);
}
export function genericResponse(success: boolean, msg?: string, data?: null) {
return { success, msg, data }
};
export function successResponse(success: boolean, error: string) {
return (c: Context) => {
return c.json({ success, error });
}
}
const authHeaderSchema = z.string().transform((arg, ctx) => {
const split = arg.split(' ');
for (const item of split) if (item.length < 6) {
ctx.addIssue("Authorization header is invalid");
return;
}
return split[1];
});
export async function authenticate(c: Context<HonoEnv>, nxt: Next) {
const secret = env["SECRET"];
if (!secret) return c.json(genericResponse(false, "Internal Server Error"), 500);
const authHeader = authHeaderSchema.safeParse(c.req.header('Authorization'));
if (authHeader.success) {
try {
const payload = JSON.parse(JSON.stringify(await verify(authHeader.data ? authHeader.data : 'not a valid token', secret)));
const profile = await Server.Profiles.get((payload as TokenFormat).sub);
if (!profile) return c.json(genericResponse(false, "Internal Server Error"), 500);
c.set('profile', profile);
return await nxt();
} catch (err) {
log.w(`Authentication failed: ${(err as Error).stack}`);
return c.json(genericResponse(false, "Internal Server Error"), 500);
}
} else return c.json(genericResponse(false, "Authorization required"), 401);
}
export function recNetResult(success: boolean, error?: string) {
return {success, error};
}
export function recNetResultResponse(c: Context<HonoEnv>, status: HTTPStatus, success: boolean, error?: string) {
return c.json(recNetResult(success, error), status as ContentfulStatusCode);
}
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 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 = 60, limit: number = 10) {
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 (
c: Context<HonoEnv>,
next: Next
) => {
const address = c.get('srcAddr');
if (address == '127.0.0.1' || address == '::1') return next();
this.#addressIncrement(address);
const hits = this.#getAddressHits(address);
if (hits && hits > this.#hitLimit) return c.json(recNetResult(false, "Rate Limited. Please try again later."), 429);
else return next();
};
}
#close() {
clearInterval(this.#intervalId);
}
}
const loginLockBodySchema = z.object({
LoginLock: z.uuidv4()
});
export const loginLockMiddleware = async (c: Context<HonoEnv>, nxt: Next) => {
function unauthorized() {
return statusResponse(c, HTTPStatus.Unauthorized);
}
if (c.req.header("Content-Type") !== "application/x-www-form-urlencoded") return unauthorized();
try {
const form = await c.req.formData();
const body = await loginLockBodySchema.safeParseAsync(Object.fromEntries(form.entries()));
if (body.success) {
if (typeof c.get('profile') == 'undefined') {
log.w(`Profile was not set, cannot validate LoginLock. Was the request authorized?`);
return statusResponse(c, HTTPStatus.InternalServerError);
}
const profile = c.get('profile');
const loginLock = await profile.Matchmaking.getLoginLock();
if (!loginLock) await profile.Matchmaking.setLoginLock(body.data.LoginLock);
else if (body.data.LoginLock !== loginLock) {
log.w(`LoginLock did not match. The token for this profile could be compromised or the client is an unknown state.`);
return unauthorized();
}
return await nxt();
} else {
log.w(`LoginLock parse failed: ${JSON.stringify(body.error)}`);
return unauthorized();
}
} catch {
return unauthorized();
}
}