Initial commit
This commit is contained in:
49
src/util/api.ts
Normal file
49
src/util/api.ts
Normal 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
53
src/util/import.ts
Normal 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
34
src/util/net.ts
Normal 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}` : ''}`;
|
||||
}
|
||||
55
src/util/steam/SteamAuthTypes.ts
Normal file
55
src/util/steam/SteamAuthTypes.ts
Normal 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;
|
||||
34
src/util/steam/SteamCommonTypes.ts
Normal file
34
src/util/steam/SteamCommonTypes.ts
Normal 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
110
src/util/steam/steam.ts
Normal 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
25
src/util/types.ts
Normal 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
30
src/util/validators.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user