Initial commit

This commit is contained in:
2025-07-25 19:00:06 -04:00
commit e604c7a437
52 changed files with 96098 additions and 0 deletions

49
src/util/api.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Context, Next } from "@hono/hono";
import { 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 { ProfileToken } from "../server/profiles/types/profile.ts";
const log = new Logging("APIUtils");
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 = Deno.env.get('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 = await verify(authHeader.data ? authHeader.data : 'not a valid token', secret);
const profile = await Server.Profiles.get((payload as ProfileToken).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);
}

53
src/util/import.ts Normal file
View File

@@ -0,0 +1,53 @@
import Logging from "@proxnet/undead-logging";
import { Hono } from "@hono/hono";
import path from "node:path";
import process from "node:process";
import { HonoEnv, RouteImport } from "./types.ts";
const debug = false;
export async function routeImporter(hono: Hono<HonoEnv>, prefix: string, paths: string[]) {
const items = await importer<RouteImport>('route', prefix, paths);
for (const route of items) hono.route(route.path, route.app);
}
export async function importer<T>(importKey: string, prefix: string, paths: string[]): Promise<T[]> {
const log = new Logging(`Importer:'${importKey}'-${prefix}`);
const items: T[] = [];
for (const pathStr of paths) {
const importPath = path.join(process.cwd(), prefix, pathStr);
if (debug) log.d(`'${importKey}' found ${importPath}`);
for await (const localPath of Deno.readDir(importPath)) {
if (localPath.isDirectory) continue;
if (localPath.isFile && localPath.name.endsWith('.ts')) {
const fullPath = path.join('file://', importPath, localPath.name);
if (debug) log.d(`'${importKey}' importing ${fullPath}`);
await import(fullPath).then(val => {
if (val[importKey]) items.push(val[importKey]);
else log.w(`Import key '${importKey}' not found on: '${fullPath}'`);
}).catch(err => {
log.e(`Could not import key '${importKey}' from ${fullPath}: ${err}`);
});
}
}
}
return items;
}
export function createHonoRoute(path: string): RouteImport {
const route: RouteImport = {
path,
app: new Hono<HonoEnv>()
}
return route
}

34
src/util/net.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Context } from "@hono/hono";
import { getConnInfo } from "@hono/hono/deno";
export function getSourceAddress(req: Request, netAddr?: Deno.NetAddr) {
let addr = '(unknown src)';
const sources = [
req.headers.get('Cf-Connecting-Ip'),
req.headers.get('X-Real-Ip'),
netAddr ? `${netAddr.hostname}:${netAddr.port}` : null
];
const first = sources.find(val => val !== null);
if (first) addr = first;
return addr;
}
export function getHonoSourceAddress(c: Context) {
let addr = '(unknown src)';
const { remote } = getConnInfo(c);
const sources = [
c.header('Cf-Connecting-Ip'),
c.header('X-Real-Ip'),
remote.address ? remote.port ? `${remote.address}:${remote.port}` : remote.address : null
];
const first = sources.find(val => val !== null);
if (first) addr = first;
return addr;
}
export function getFullPathFromUrl(url: URL) {
const params = url.searchParams.toString();
return `${url.pathname}${params ? `?${params}` : ''}`;
}

View File

@@ -0,0 +1,55 @@
/* 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/>. */
interface AuthenticateUserTicketSuccess {
result: 'OK';
steamid: string;
ownersteamid: string;
vacbanned: boolean;
publisherbanned: boolean;
}
interface AuthenticateUserTicketError {
errorcode: number;
errordesc: string;
}
export interface SteamAuthRes {
response: {
error?: AuthenticateUserTicketError;
params?: AuthenticateUserTicketSuccess;
};
}
export enum SteamAuthResult {
Success,
Failure,
NotConfigured
}
interface SteamAuthBase {
valid: SteamAuthResult;
}
interface SteamAuthSuccess extends SteamAuthBase {
valid: SteamAuthResult.Success;
res: AuthenticateUserTicketSuccess;
}
interface SteamAuthFailure extends SteamAuthBase {
valid: SteamAuthResult.Failure;
res: AuthenticateUserTicketError;
}
interface SteamAuthNotConfigured extends SteamAuthBase {
valid: SteamAuthResult.NotConfigured;
}
export type SteamAuth = SteamAuthSuccess | SteamAuthFailure | SteamAuthNotConfigured;

View File

@@ -0,0 +1,34 @@
/* 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/>. */
import type { PersonaState, CommunityVisibilityState } from "./steam.ts";
export interface SteamPlayer {
steamid: string;
personaname: string;
profileurl: string;
avatar: string;
avatarmedium: string;
avatarfull: string;
personastate: PersonaState;
communityvisibilitystate: CommunityVisibilityState;
profilestate?: 1;
lastlogoff?: number;
commentpermission?: 1;
loccountrycode?: string; // undocumented but is seen in API responses - may or may not be undefined
locstatecode?: string; // undocumented but is seen in API responses - may or may not be undefined
}

110
src/util/steam/steam.ts Normal file
View File

@@ -0,0 +1,110 @@
/* 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/>. */
import Logging from "@proxnet/undead-logging";
import { SteamAuth, SteamAuthResult, SteamAuthRes } from "./SteamAuthTypes.ts";
import { SteamPlayer } from "./SteamCommonTypes.ts";
const log = new Logging("Steam");
const steamkey = Deno.env.get("STEAMKEY");
function buildSteamUrl(steaminterface: string, endpoint: string) {
return `https://api.steampowered.com/${steaminterface}/${endpoint}`;
}
export enum PersonaState {
Offline,
Online,
Busy,
Away,
Snooze,
LookingToTrade,
LookingToPlay
}
export enum CommunityVisibilityState {
NotVisible,
PubliclyVisible = 3
}
class SteamBase {
async GetPlayerSummaries(steamids: string[]) {
if (!steamkey) return null;
const params = new URLSearchParams();
params.append('key', steamkey);
params.append('steamids', steamids.join(','))
try {
const res = await fetch(`${buildSteamUrl('ISteamUser', 'GetPlayerSummaries/v2')}?${params}`);
if (res.status !== 200) return null;
const resjson = await res.json() as { response: { players: SteamPlayer[] } };
return resjson.response.players;
} catch (err) {
log.e(`Could not fetch Steam player summaries: ${(err as Error).stack}`);
return null;
}
}
async AuthenticateUserTicket(ticket: string, userid: string): Promise<SteamAuth> {
if (!steamkey) return { valid: SteamAuthResult.NotConfigured }; // always authenticate if no steam API key was found
const params = new URLSearchParams();
params.append('key', steamkey);
params.append('appid', "471710");
params.append('ticket', ticket);
try {
const res = await fetch(`${buildSteamUrl('ISteamUserAuth', 'AuthenticateUserTicket/v1')}?${params}`);
const resjson = (await res.json()) as SteamAuthRes;
if (resjson.response.error) {
log.w(`Steam Authentication failed: (${resjson.response.error.errorcode}) ${resjson.response.error.errordesc}`);
// add more error codes later if needed
const conditions = [
resjson.response.error.errorcode == 100
].includes(true);
if (conditions) log.w('This error indicates a client problem.');
return { valid: SteamAuthResult.Failure, res: resjson.response.error };
}
//log.d(JSON.stringify(resjson.response));
if (resjson.response.params) {
// since rec room is not eligible for family sharing on Steam
const valid = resjson.response.params.steamid === userid && resjson.response.params.ownersteamid === userid;
if (valid) return { valid: SteamAuthResult.Success, res: resjson.response.params }
else throw new Error('`ownersteamid` is not equal to `steamid`, report me to GC devs!');
}
else {
log.w("Steam Authentication failed: Steam response did not contain params or error! This should never be logged!");
return { valid: SteamAuthResult.Failure, res: { errorcode: -1, errordesc: 'Steam response error' } };
}
} catch (err) {
log.w(`Steam Authentication failed: ${(err as Error).message}`);
return { valid: SteamAuthResult.Failure, res: { errorcode: -1, errordesc: 'Steam response error' } };
}
}
}
const Steam = new SteamBase();
export default Steam;

25
src/util/types.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Hono } from "@hono/hono";
import type { z } from "zod";
import Profile from "../server/profiles/profile.ts";
export type HonoEnv = {
Variables: HonoVars
}
export interface RouteImport {
path: string
app: Hono<HonoEnv>
}
export interface HonoVars {
profile: Profile,
srcAddr: string
}
export type NoBody = Record<PropertyKey, never>;
// Validator-related types
export type ValidatorTarget = 'query' | 'json' | 'form' | 'header' | 'param' | 'cookie';
export type SchemaInput<T extends z.ZodSchema> = z.input<T>;
export type SchemaOutput<T extends z.ZodSchema> = z.output<T>;

30
src/util/validators.ts Normal file
View File

@@ -0,0 +1,30 @@
import { zValidator } from "@hono/zod-validator";
import type { MiddlewareHandler } from "@hono/hono";
import { z } from "zod";
import type { HonoEnv } from "./types.ts";
// thanks claude, this hurt my brain!
export const typedZValidator = <T extends z.ZodSchema>(
target: 'query' | 'json' | 'form' | 'header' | 'param' | 'cookie',
schema: T
) => {
return zValidator(target, schema) as MiddlewareHandler<
HonoEnv,
string,
{
in: { [K in typeof target]: z.input<T> };
out: { [K in typeof target]: z.output<T> };
}
>;
};
export const transformStringToEnum = <T>(anEnum: { [s: number]: string }) => {
return (arg: string, ctx: z.RefinementCtx<string>) => {
const int = parseInt(arg);
if (isNaN(int)) ctx.addIssue("Must be parseable as a number");
else {
if (anEnum[int]) return int as T;
else ctx.addIssue("Number must be a valid enum member");
}
}
}