I can't be bothered to even explain what happene here

This commit is contained in:
2025-02-07 23:18:04 -05:00
parent d982567c7b
commit bc3443b1dc
21 changed files with 994 additions and 177 deletions

View File

@@ -1,8 +1,22 @@
import { Context, Next } from "@oak/oak";
// @ts-types = "npm:@types/express"
import express from "express";
import Logging from "@proxnet/undead-logging";
const log = new Logging('APIUtils');
interface 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 = '';
@@ -18,12 +32,11 @@ export function generateRandomString(length: number) {
const instanceId = generateRandomString(128);
export function checkQueryTypes<T>(typeDef: T) {
return (ctx: Context, nxt: Next) => {
return (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
for (const key in typeDef) {
if (typeof Object.fromEntries(ctx.request.url.searchParams)[key] !== typeof (typeDef)[key]) {
ctx.response.status = 400;
setContentType(ctx, 'application/json');
ctx.response.body = JSON.stringify(genericResponseFormat(true, "One or more query parameters were invalid or not found."));
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;
}
}
@@ -31,13 +44,12 @@ export function checkQueryTypes<T>(typeDef: T) {
};
}
export function checkBodyTypes<T>(typeDef: T) {
return async (ctx: Context, nxt: Next) => {
return (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
for (const key in typeDef) {
if (typeof (await ctx.request.body.json())[key] !== typeof (typeDef)[key]) {
if (typeof rq.body[key] !== typeof (typeDef)[key]) {
log.e(`Body check for key '${key}' failed.`);
ctx.response.status = 400;
setContentType(ctx, 'application/json');
ctx.response.body = JSON.stringify(genericResponseFormat(true, "One or more body values were invalid or not found."));
rs.statusCode = 400;
rs.json(genericResponseFormat(true, "One or more body values were invalid or not found."));
return;
}
}
@@ -49,9 +61,8 @@ export function genericResponseFormat(failure: boolean, msg: string | null = nul
return { failed: failure, instance: instanceId, message: msg, data: data };
}
export function genericResponse(failure: boolean, msg: string | null = null, data = null) {
return (ctx: Context) => {
setContentType(ctx, 'application/json');
ctx.response.body = JSON.stringify({ failed: failure, instance: instanceId, message: msg, data: data });
return (_rq: express.Request, rs: express.Response) => {
rs.json({ failed: failure, instance: instanceId, message: msg, data: data });
};
}
type RecNetResponse = {
@@ -60,29 +71,35 @@ type RecNetResponse = {
};
export function RecNetResponse(success: boolean, message: string) {
const msg: RecNetResponse = { Success: success, Message: message };
return (ctx: Context) => {
setContentType(ctx, 'application/json');
ctx.response.body = JSON.stringify(msg);
return (_rq: express.Request, rs: express.Response) => {
rs.json(msg);
}
}
export async function logBody(ctx: Context, nxt: Next) {
export function logBody(rq: express.Request, _rs: express.Response, nxt: express.NextFunction) {
nxt();
log.d(`Request body: ${JSON.stringify(await ctx.request.body.text())}`);
log.d(`Request body: ${JSON.stringify(rq.body)}`);
}
export function emptyArrayResponse(ctx: Context) {
setContentType(ctx, 'application/json');
ctx.response.body = JSON.stringify([]);
export function emptyArrayResponse(_rq: express.Request, rs: express.Response) {
rs.json([]);
}
export function setJSONBody(ctx: Context, obj: object) {
ctx.response.type = 'json';
ctx.response.body = JSON.stringify(obj);
export function getSrcIpDefault(rq: express.Request) {
const cfIp = rq.header('cf-connecting-ip');
if (cfIp !== undefined) return cfIp;
const xrIp = rq.header('x-real-ip');
if (xrIp !== undefined) return xrIp;
const ip = typeof rq.ip === 'undefined' ? '(unknown source)' : rq.ip;
return ip;
}
export function setContentType(ctx: Context, type: string) {
ctx.response.headers.set('Content-Type', type);
export function statusResponse(code: number) {
return (_rq: express.Request, rs: express.Response) => {
rs.sendStatus(code);
}
}
export * as APIUtils from "./apiutils.ts"

View File

@@ -1,6 +1,5 @@
import Logging from "@proxnet/undead-logging";
import * as fs from "node:fs";
import process from "node:process";
const log = new Logging("Config");
@@ -14,13 +13,17 @@ type RedisConfiguration = {
type WebConfiguration = {
port: number,
host: string
host: string,
nameserverHost: string,
secureNameserverHost: boolean
}
type PublicConfiguration = {
serverName: string,
owner: string,
motd: string
motd: string,
levelScale: number,
maxLevels: number
}
type LoggingConfiguration = {
@@ -34,15 +37,20 @@ type DiscordConfiguration = {
guildId: string
}
type GalvanicConfiguration = {
type SecretConfiguration = {
authSecret: string
}
export type GalvanicConfiguration = {
redis: RedisConfiguration,
web: WebConfiguration,
public: PublicConfiguration,
logging: LoggingConfiguration,
discord: DiscordConfiguration
discord: DiscordConfiguration,
secrets: SecretConfiguration
}
const defaultConfig: GalvanicConfiguration = {
export const defaultConfig: GalvanicConfiguration = {
redis: {
host: "127.0.0.1",
port: 6379,
@@ -52,12 +60,16 @@ const defaultConfig: GalvanicConfiguration = {
},
web: {
port: 3000,
host: "127.0.0.1"
host: "127.0.0.1",
nameserverHost: "127.0.0.1:3000",
secureNameserverHost: false
},
public: {
serverName: "Galvanic Corrosion",
owner: "John Doe",
motd: "The narwhal bacons at midnight"
motd: "The narwhal bacons at midnight",
levelScale: 1,
maxLevels: 30
},
logging: {
debug: false,
@@ -67,6 +79,9 @@ const defaultConfig: GalvanicConfiguration = {
token: "replace-me",
guildId: "replace-me",
clientId: "replace-me"
},
secrets: {
authSecret: "CHANGE-ME-PLEASE"
}
}
@@ -77,19 +92,7 @@ try {
config = JSON.parse(fs.readFileSync('./config.json').toString());
} catch (err) {
log.e(`Could not get config: ${err}`);
process.exit(1);
}
/**
* Looks for a certain file in the current directory that shouldn't exist on the first run.
* Returns `false` when GC has ran at least once
*/
export function firstRun() {
if (!fs.existsSync('./firstrun')) return true;
else {
fs.writeFile('./firstrun', "", () => {});
return false;
}
Deno.exit(1);
}
/** Does the configuration file exist on the disk? */

48
src/data/auth.ts Normal file
View File

@@ -0,0 +1,48 @@
import { encode, decode } from "@gz/jwt";
import { Config, GalvanicConfiguration } from "../config.ts";
import Logging from "@proxnet/undead-logging";
const log = new Logging("Auth");
const config = Config.getConfig() as GalvanicConfiguration;
type TokenFormat = {
iss: string;
sub: number;
nbf: number;
iat: number;
exp: number;
}
export class GameAuthContext {
valid: boolean | null = null;
#rawToken: string
playerId: number | null = null;
constructor(token: string) {
this.#rawToken = token;
}
async decode() {
try {
const decoded = await decode(this.#rawToken, config.secrets.authSecret) as TokenFormat;
this.playerId = decoded.sub || null;
const now = Math.round(Date.now() / 1000);
this.valid = true;
if (decoded.exp < now) this.valid = false;
if (decoded.nbf > now) this.valid = false;
} catch (e) {
this.valid = false;
log.w(`Token decode failed: ${(e as Error).stack}`);
}
}
}
export * as Authentication from "./auth.ts";

65
src/data/config.ts Normal file
View File

@@ -0,0 +1,65 @@
import { Config } from "../config.ts";
import { Objectives } from "./objectives.ts";
export type Config = {
Key: string,
Value: string
}
export type LevelProgressionItem = {
Level: number,
RequiredXp: number
}
export type PublicConfig = {
MessageOfTheDay: string,
CdnBaseUri: string,
MatchmakingParams: {
PreferFullRoomsFrequency: number,
PreferEmptyRoomsFrequency: number
},
ServerMaintenance: {
StartsInMinutes: number
},
LevelProgressionMaps: LevelProgressionItem[],
DailyObjectives: Objectives.Objective[][],
ConfigTable: Config[],
PhotonConfig: {
CrcCheckEnabled: boolean,
EnableServerTracingAfterDisconnect: boolean
}
}
export function getConfig() {
const c = Config.getConfig();
if (typeof c == 'undefined') return null;
const config = c as Config.GalvanicConfiguration;
function generateLevelProgressionMap() {
const m: LevelProgressionItem[] = [];
for (let i = 0; i < config.public.maxLevels + 1; i++)
m.push({Level: i, RequiredXp: Math.round(i * config.public.levelScale * 20)});
return m;
}
const conf: PublicConfig = {
MessageOfTheDay: config.public.motd,
CdnBaseUri: `${config.web.secureNameserverHost ? 'https' : 'http'}://${c.web.nameserverHost}/{0}`,
MatchmakingParams: {
PreferFullRoomsFrequency: 1,
PreferEmptyRoomsFrequency: 0
},
ServerMaintenance: {
StartsInMinutes: 0
},
LevelProgressionMaps: generateLevelProgressionMap(),
DailyObjectives: [],
ConfigTable: [],
PhotonConfig: {
CrcCheckEnabled: false,
EnableServerTracingAfterDisconnect: false
}
}
return conf;
}
export * as GameConfigs from "./config.ts";

View File

@@ -0,0 +1,17 @@
enum AvatarItemType {
None = -1,
Hat,
BackHead,
Hair,
Eye = 10,
Mouth = 20,
Neck = 100,
Shirt,
Belt,
Pocket,
TeamJersey,
Wrist = 200,
Glove,
Watch,
TeamWrist
}

View File

@@ -0,0 +1,61 @@
export enum Consumable {
ASSORTED_DONUTS,
SUPREME_PIZZA,
ROOT_BEER,
CHOCOLATE_FROSTED_DONUTS,
CHEESE_PIZZA,
PEPPERONI_PIZZA,
GLAZED_DONUTS
}
const ids = [
"ZuvkidodzkuOfGLDnTOFyg",
"wUCIKdJSvEmiQHYMyx4X4w",
"JfnVXFmilU6ysv-VbTAe3A",
"mMCGPgK3tki5S_15q2Z81A",
"5hIAZ9wg5EyG1cILf4FS2A",
"mq23W-RSP0G8iGNLdrcpUw",
"7OZ5AE3uuUyqa0P-2W1ptg"
] as const;
export class ConsumableSelection {
type: Consumable;
guid: string;
constructor(type: Consumable) {
this.type = type;
this.guid = ids[type];
}
}
export class ConsumableBuilder {
Id: number;
ConsumableItemDesc: string;
CreatedAt: string;
Count: number;
UnlockedLevel: number;
IsActive: boolean;
constructor(selection: ConsumableSelection, id: number, createdAt: Date, count: number, active: boolean) {
this.Id = id;
this.ConsumableItemDesc = selection.guid;
this.CreatedAt = createdAt.toUTCString();
this.Count = count;
this.UnlockedLevel = 0; // All players have access to every consumable - avatars and equipment are different
this.IsActive = active;
}
}

96
src/data/objectives.ts Normal file
View File

@@ -0,0 +1,96 @@
export enum ObjectiveType {
Default = -1,
FirstSessionOfDay = 1,
AddAFriend,
PartyUp,
AllOtherChallenges,
LevelUp,
CheerAPlayer,
PointedAtPlayer,
CheerARoom,
SubscribeToPlayer,
DailyObjective1,
DailyObjective2,
DailyObjective3,
AllDailyObjectives,
CompleteAnyDaily,
CompleteAnyWeekly,
OOBE_GoToLockerRoom = 20,
OOBE_GoToActivity,
OOBE_FinishActivity,
NUX_PunchcardObjective = 25,
NUX_AllPunchcardObjectives,
GoToRecCenter = 30,
FinishActivity,
VisitACustomRoom,
CreateACustomRoom,
ScoreBasketInRecCenter = 35,
UploadPhotoToRecNet,
UpdatePlayerBio,
SaveOutfitSlot,
PurchaseClothingItem,
PurchaseNonClothingItem,
CharadesGames = 100,
CharadesWinsPerformer,
CharadesWinsGuesser,
DiscGolfWins = 200,
DiscGolfGames,
DiscGolfHolesUnderPar,
DodgeballWins = 300,
DodgeballGames,
DodgeballHits,
PaddleballGames = 400,
PaddleballWins,
PaddleballScores,
PaintballAnyModeGames = 500,
PaintballAnyModeWins,
PaintballAnyModeHits,
PaintballCTFWins = 600,
PaintballCTFGames,
PaintballCTFHits,
PaintballFlagCaptures,
PaintballTeamBattleWins = 700,
PaintballTeamBattleGames,
PaintballTeamBattleHits,
PaintballFreeForAllWins = 710,
PaintballFreeForAllGames,
PaintballFreeForAllHits,
SoccerWins = 800,
SoccerGames,
SoccerGoals,
QuestGames = 1000,
QuestWins,
QuestPlayerRevives,
QuestEnemyKills,
QuestGames_Goblin1 = 1010,
QuestWins_Goblin1,
QuestPlayerRevives_Goblin1,
QuestEnemyKills_Goblin1,
QuestGames_Goblin2 = 1020,
QuestWins_Goblin2,
QuestPlayerRevives_Goblin2,
QuestEnemyKills_Goblin2,
QuestGames_Scifi1 = 1030,
QuestWins_Scifi1,
QuestPlayerRevives_Scifi1,
QuestEnemyKills_Scifi1,
QuestGames_Pirate1 = 1040,
QuestWins_Pirate1,
QuestPlayerRevives_Pirate1,
QuestEnemyKills_Pirate1,
ArenaGames = 2000,
ArenaWins,
ArenaPlayerRevives,
ArenaHeroTags,
ArenaBotTags,
RecRoyaleGames = 3000,
RecRoyaleWins,
RecRoyaleTags
}
export type Objective = {
type: ObjectiveType,
score: number
}
export * as Objectives from "./objectives.ts";

12
src/data/users.ts Normal file
View File

@@ -0,0 +1,12 @@
interface UserInitOptions {
username: string,
password: string,
}
export class User {
static init() {
}
}

View File

@@ -1,14 +1,13 @@
import { Redis } from "ioredis";
import * as Config from "./config.ts";
import Logging from "@proxnet/undead-logging";
import process from "node:process";
const log = new Logging("RedisDB");
const config = Config.getConfig();
if (typeof config == 'undefined') {
log.e(`Cannot start: Redis configuration failed`);
process.exit(1);
Deno.exit(1);
}
let shuttingDown = false;
@@ -16,18 +15,19 @@ Deno.addSignalListener('SIGINT', () => {
if (shuttingDown) return;
shuttingDown = true;
log.n('Disconnecting from Redis');
if (typeof Database !== 'undefined') Database.quit();
Database.quit();
});
export let Database: Redis | undefined;
export const Database = new Redis({
port: config?.redis.port,
host: config?.redis.host,
username: config?.redis.username == "" ? undefined : config?.redis.username,
password: config?.redis.password == "" ? undefined : config?.redis.password,
db: config?.redis.db,
lazyConnect: true
});
export function connectToRedis() {
Database = new Redis({
port: config?.redis.port,
host: config?.redis.host,
username: config?.redis.username == "" ? undefined : config?.redis.username,
password: config?.redis.password == "" ? undefined : config?.redis.password,
db: config?.redis.db
});
Database.connect();
log.i(`Connected to Redis`);
}
@@ -35,13 +35,18 @@ export function buildKey(...args: string[]) {
return args.join(':');
}
export const KeyGroups = {
Config: {
Root: "config",
Dynamic: "dynamic"
},
Accounts: {
Ids: "account-ids",
Usernames: "account-usernames",
DisplayNames: "account-displaynames",
XP: "account-scores",
Developers: "account-developers",
ProfileImages: "account-images"
Root: "accounts",
Ids: "ids",
Usernames: "usernames",
DisplayNames: "displaynames",
XP: "scores",
Developers: "developers",
ProfileImages: "images"
}
}
export * as Redis from "./db.ts";

View File

@@ -1,14 +1,13 @@
import * as discord from "discord.js";
import { Config } from "./config.ts";
import Logging from "@proxnet/undead-logging";
import process from "node:process";
const log = new Logging("Discord");
const config = Config.getConfig();
if (typeof config == 'undefined') {
log.e(`Cannot start: Discord configuration is unavailable`);
process.exit(1);
Deno.exit(1);
}
export const client = new discord.Client({ intents: [discord.GatewayIntentBits.Guilds, discord.GatewayIntentBits.GuildPresences] });

48
src/dynamicconfig.ts Normal file
View File

@@ -0,0 +1,48 @@
import { KeyGroups, Redis } from "./db.ts";
export enum ResultType {
Found,
NotFound
}
interface ConfigResult {
Status: ResultType,
Data: string | null
}
interface ConfigMResult {
Status: ResultType,
Data: (string | null)[] | null
}
/** Get a dyamic config. */
export async function getConfig(key: string) {
const res: ConfigResult = {
Status: ResultType.Found,
Data: null
}
const data = await Redis.Database.get(`${KeyGroups.Config.Root}:${KeyGroups.Config.Dynamic}:${key}`);
if (data == null) res.Status = ResultType.NotFound;
else res.Data = data;
return res;
}
export async function mgetConfig(...keys: string[]) {
const res: ConfigMResult = {
Status: ResultType.Found,
Data: null
}
const data = await Redis.Database.mget(...keys);
res.Data = data;
return res;
}
/** Set a dynamic config. */
export async function setConfig(key: string, value: string) {
await Redis.Database.set(`${KeyGroups.Config.Root}:${KeyGroups.Config.Dynamic}:${key}`, value);
}
export * as DynamicConfig from "./dynamicconfig.ts";

View File

@@ -1,11 +1,11 @@
import Logging from "@proxnet/undead-logging";
import * as Log from "@proxnet/undead-logging";
import * as Config from "./config.ts";
import { Application, Router } from "@oak/oak";
// @ts-types = 'npm:@types/express'
import express from "express";
import { Redis } from "./db.ts";
import { Discord } from "./discord.ts";
import { APIUtils } from "./apiutils.ts";
const log = new Logging("Main");
const log = new Log.default("Main");
log.i(`Starting Galvanic Corrosion..`);
@@ -15,16 +15,44 @@ if (typeof config == 'undefined') {
log.e('Cannot start: Configuration is undefined');
Deno.exit(1);
}
if (config.secrets.authSecret == Config.defaultConfig.secrets.authSecret) {
log.e(`Cannot start: Auth secret is default. Please change 'secrets.authSecret' in 'config.json'`);
Deno.exit(1);
}
Log.MessageTypeVisibility.Network = config.logging.network;
Log.MessageTypeVisibility.Debug = config.logging.debug;
const port = config.web.port;
const host = config.web.host;
log.i(`Starting HTTP server on http://${host}:${port}`);
const abortController = new AbortController();
const app = new Application();
const app = express();
app.use(new Router().all('/', APIUtils.genericResponse(false, `${config?.public.serverName} - ${config?.public.motd}`)).routes());
app.disable('etag');
app.disable('x-powered-by');
app.use((rq: express.Request, rs: express.Response, nxt: express.NextFunction) => {
rs.locals.auth = null;
log.n(`${APIUtils.getSrcIpDefault(rq)} ${rq.method} ${rq.originalUrl}`);
nxt();
});
app.use('/', APIUtils.genericResponse(false, `${config?.public.serverName} - ${config?.public.motd}`));
// content routes
const nameserverRouter = await import('./routes/nameserver.ts');
const apiRouter = await import('./routes/api.ts');
app.use(nameserverRouter.route.path, nameserverRouter.route.router);
app.use(apiRouter.route.path, apiRouter.route.router);
app.use((rq: express.Request, rs: express.Response) => {
log.e(`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
rs.statusCode = 404;
rs.json(APIUtils.genericResponseFormat(true, 'Endpoint not found. Check your syntax and/or method.'));
});
try {
log.i(`Connecting to Redis..`);
@@ -35,18 +63,22 @@ try {
}
try {
app.listen({port: port, hostname: host, signal: abortController.signal });
const http = app.listen(config.web.port, config.web.host, () => {
log.n(`Listening on http://${config.web.host}`);
let shuttingDown = false;
Deno.addSignalListener('SIGINT', () => {
if (shuttingDown) return;
shuttingDown = true;
log.i(`Shutting down`);
http.close();
http.removeAllListeners();
});
});
} catch (err) {
log.e(`Cannot start: Network could not be initalized. ${err}`);
Deno.exit(1);
}
Discord.login();
let shuttingDown = false;
Deno.addSignalListener('SIGINT', () => {
if (shuttingDown) return;
shuttingDown = true;
log.n(`Shutting down`);
abortController.abort();
});
//Discord.login(); do not use for now

8
src/routes/api.ts Normal file
View File

@@ -0,0 +1,8 @@
import { route as VersionCheckRoute } from "./api/versioncheck.ts";
import { route as ConfigRoute } from "./api/config.ts";
import { APIUtils } from "../apiutils.ts";
export const route = APIUtils.createRouter('/api');
route.router.use(VersionCheckRoute.router);
route.router.use(ConfigRoute.router);

10
src/routes/api/config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { APIUtils } from "../../apiutils.ts";
import { GameConfigs } from "../../data/config.ts";
export const route = APIUtils.createRouter('/config');
route.router.get('/v2', (rq, rs) => {
const config = GameConfigs.getConfig();
if (config == null) rs.sendStatus(500);
else rs.json(config);
});

View File

@@ -0,0 +1,24 @@
import { APIUtils } from "../../apiutils.ts";
export const route = APIUtils.createRouter('/versioncheck');
const validVersion = '20191120';
type ValidVersionResponse = {
ValidVersion: boolean
}
route.router.get('/v3', (rq, rs) => {
const requestedVer = rq.query['v'];
if (typeof requestedVer !== 'string' || requestedVer !== validVersion) {
const res: ValidVersionResponse = {
ValidVersion: false
}
rs.json(res);
} else {
const res: ValidVersionResponse = {
ValidVersion: true
}
rs.json(res);
}
});

40
src/routes/nameserver.ts Normal file
View File

@@ -0,0 +1,40 @@
import { APIUtils } from "../apiutils.ts";
import { Config } from "../config.ts";
const config = Config.getConfig() as Config.GalvanicConfiguration;
const protocol = config.web.secureNameserverHost ? 'https' : 'http';
export const route = APIUtils.createRouter('/ns');
type NameserverHosts = {
Auth: string,
API: string,
WWW: string,
Notifications: string,
Images: string,
CDN: string,
Commerce: string,
Matchmaking: string,
Storage: string,
Chat: string,
Leaderboard: string
}
const path = `${protocol}://${config.web.nameserverHost}`;
const nameserver: NameserverHosts = {
Auth: path,
API: path,
WWW: path,
Notifications: path,
Images: path,
CDN: path,
Commerce: path,
Matchmaking: path,
Storage: path,
Chat: path,
Leaderboard: path
}
route.router.get('*', (_rq, rs) => {
rs.json(nameserver);
});

9
src/types/express.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Authentication } from "../data/auth.ts";
declare global {
namespace Express {
interface Locals {
auth: Authentication.GameAuthContext | null
}
}
}