User stuff, config structure changes, web panel start, versioncheck fix, api start, recaptcha support for web panel

This commit is contained in:
2025-02-08 21:19:25 -05:00
parent bc3443b1dc
commit 73e9b72ad4
36 changed files with 5526 additions and 87 deletions

View File

@@ -4,7 +4,7 @@ import Logging from "@proxnet/undead-logging";
const log = new Logging('APIUtils');
interface AppRouter {
type AppRouter = {
path: string,
router: express.Router
}
@@ -57,10 +57,10 @@ export function checkBodyTypes<T>(typeDef: T) {
};
}
export function genericResponseFormat(failure: boolean, msg: string | null = null, data = null) {
export function genericResponseFormat(failure: boolean, msg: string | null = null, data: object | null = null) {
return { failed: failure, instance: instanceId, message: msg, data: data };
}
export function genericResponse(failure: boolean, msg: string | null = null, data = null) {
export function genericResponse(failure: boolean, msg: string | null = null, data: object | null = null) {
return (_rq: express.Request, rs: express.Response) => {
rs.json({ failed: failure, instance: instanceId, message: msg, data: data });
};
@@ -102,4 +102,67 @@ export function statusResponse(code: number) {
}
}
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, 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 * as APIUtils from "./apiutils.ts"

View File

@@ -41,13 +41,19 @@ type SecretConfiguration = {
authSecret: string
}
type RecaptchaConfiguration = {
sitekey: string,
secret: string
}
export type GalvanicConfiguration = {
redis: RedisConfiguration,
web: WebConfiguration,
public: PublicConfiguration,
logging: LoggingConfiguration,
discord: DiscordConfiguration,
secrets: SecretConfiguration
discord: DiscordConfiguration | null,
secrets: SecretConfiguration,
recaptcha: RecaptchaConfiguration | null
}
export const defaultConfig: GalvanicConfiguration = {
@@ -75,14 +81,11 @@ export const defaultConfig: GalvanicConfiguration = {
debug: false,
network: false
},
discord: {
token: "replace-me",
guildId: "replace-me",
clientId: "replace-me"
},
discord: null,
secrets: {
authSecret: "CHANGE-ME-PLEASE"
}
},
recaptcha: null
}
/** The current configuration. Read and parsed only during startup. */
@@ -100,7 +103,7 @@ export function configurationExists() {
return fs.existsSync('./config.json');
}
/** Place the default configuration in the current directory. */
/** Place [or overwrite] the [existing] default configuration in the current directory */
export function generateDefaultConfig() {
fs.writeFileSync('./config.json', JSON.stringify(defaultConfig, undefined, ' '));
}

View File

@@ -1,4 +1,5 @@
import { Config } from "../config.ts";
import { Redis } from "../db.ts";
import { Objectives } from "./objectives.ts";
export type Config = {
@@ -10,22 +11,12 @@ export type LevelProgressionItem = {
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
}
ConfigTable: Config[]
}
export function getConfig() {
@@ -41,25 +32,37 @@ export function getConfig() {
}
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
}
ConfigTable: []
}
return conf;
}
export async function getAllGameConfigs() {
try {
const gameConfigs = new Map<string, string>();
const val = await Redis.Database.hgetall(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game));
for (const key of Object.keys(val))
gameConfigs.set(key, val[key]);
return gameConfigs;
} catch (error) {
console.error("Error fetching game configs:", error);
throw error;
}
}
export function setGameConfig(key: string, value: string) {
return Redis.Database.hset(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game));
}
export function getGameConfig(key: string) {
return Redis.Database.hget(Redis.buildKey(Redis.KeyGroups.Config.Root, Redis.KeyGroups.Config.Game), key);
}
export * as GameConfigs from "./config.ts";

View File

@@ -48,14 +48,12 @@ export class ConsumableBuilder {
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;
}
}

45
src/data/recaptcha.ts Normal file
View File

@@ -0,0 +1,45 @@
import Logging from "@proxnet/undead-logging";
import { Config } from "../config.ts";
const log = new Logging("ReCAPTCHA");
type SiteVerifyParams = {
secret: string,
response: string,
remoteip?: string
}
type SiteVerifyResponse = {
success: boolean,
challenge_ts: string,
hostname: string,
"error-codes": string[]
}
class ReCAPTCHABase {
async siteVerify(response: string, remoteip?: string) {
const config = Config.getConfig();
if (typeof config == 'undefined') return null;
if (config.recaptcha == null) {
log.e("Tried to verify ReCAPTCHA, but the config is null!");
return null;
}
const body: SiteVerifyParams = {
secret: config.recaptcha.secret,
response: response,
remoteip: remoteip
}
const res = await fetch('https://google.com/recaptcha/api/siteverify', {
method: "POST",
body: JSON.stringify(body)
});
const resBody = await res.json() as SiteVerifyResponse;
return resBody.success
}
}
const Recaptcha = new ReCAPTCHABase();
export default Recaptcha;

View File

@@ -1,12 +1,101 @@
interface UserInitOptions {
import * as bcrypt from "bcrypt";
import { Redis } from "../db.ts";
import Logging from "@proxnet/undead-logging";
const log = new Logging("UserConstruct");
type UserInitOptions = {
username: string,
password: string,
}
type UserCreatedObj = {
user: User,
backupcode: string
}
function randomASCII() {
const codes = crypto.getRandomValues(new Uint8Array(512));
const filteredCodes = codes.filter(val => (val >= 48 && val <= 57) || (val >= 65 && val <= 90) || (val >= 97 && val <= 122) );
let str = String.fromCharCode(...filteredCodes);
if (str.length < 32) str = randomASCII();
return str.substring(0, 32);
}
export class User {
static init() {
static async exists(username: string) {
return (await Redis.Database.exists(Redis.buildKey(Redis.KeyGroups.Usernames, username))) == 1;
}
/**
* Create a user
* @returns A `UserCreatedObj` with a reference to the new user if one was created, else `null` if the username already exists.
*/
static async init(options: UserInitOptions) {
if (await User.exists(options.username)) return null;
const uuid = crypto.randomUUID();
const backup = randomASCII();
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Usernames, options.username), uuid);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.Username), options.username);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.BackupCode), backup);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, uuid, Redis.KeyGroups.Users.Password), await bcrypt.hash(options.password));
const user = new User(uuid);
const res: UserCreatedObj = {
user: user,
backupcode: backup
}
return res;
}
/**
* Get a User by their username
* @returns A `User` is one was found, else `null`
*/
static async byName(username: string) {
const uuid = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Usernames, username));
if (uuid == null) return null;
else return new User(uuid);
}
#uuid: string;
constructor(uuid: string) {
this.#uuid = uuid;
}
getUuid() {
return this.#uuid;
}
async validatePassword(password: string) {
const hash = await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Password));
if (hash == null) throw new Error(`Hash for user ${this.#uuid} was not found`);
return await bcrypt.compare(password, hash);
}
async setPassword(password: string) {
const hash = await bcrypt.hash(password);
Redis.Database.set(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Password), hash);
}
async getBackupCode() {
return await Redis.Database.get(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.BackupCode));
}
async getAssociatedProfiles() {
const list = await Redis.Database.smembers(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles));
return new Set<number>(list.filter(val => !Number.isNaN(parseInt(val, 10))).map(val => parseInt(val, 10)));
}
async removeAssociatedProfile(id: number) {
await Redis.Database.srem(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles), id);
}
async addAssociatedProfile(id: number) {
await Redis.Database.sadd(Redis.buildKey(Redis.KeyGroups.Users.Root, this.#uuid, Redis.KeyGroups.Users.Profiles), id);
}
}

View File

@@ -1,8 +1,9 @@
import { Redis } from "ioredis";
import * as Config from "./config.ts";
import Logging from "@proxnet/undead-logging";
import chalk from "npm:chalk@^5.3.0";
const log = new Logging("RedisDB");
const log = new Logging("Redis");
const config = Config.getConfig();
if (typeof config == 'undefined') {
@@ -26,10 +27,19 @@ export const Database = new Redis({
db: config?.redis.db,
lazyConnect: true
});
export function connectToRedis() {
Database.connect();
Database.on('connect', async () => {
log.i(`Connected to Redis`);
}
if (Deno.args.includes('--db-flush')) await Database.flushall(() => {
log.w(`${chalk.inverse('The database was flushed.')}`);
});
});
Database.on('connecting', () => {
log.i('Connecting to Redis..');
});
Database.on('error', (err) => {
log.e(`Redis error: ${err.stack}`);
});
export function buildKey(...args: string[]) {
return args.join(':');
@@ -37,16 +47,21 @@ export function buildKey(...args: string[]) {
export const KeyGroups = {
Config: {
Root: "config",
Dynamic: "dynamic"
Dynamic: "dynamic",
Game: "game"
},
Accounts: {
Root: "accounts",
Ids: "ids",
Usernames: "usernames",
DisplayNames: "displaynames",
XP: "scores",
Developers: "developers",
ProfileImages: "images"
Ids: "profile-ids",
Profiles: {
Root: "profiles"
},
Usernames: "usernames",
Users: {
Root: "users",
Username: "username",
Password: "password",
BackupCode: "backupcode",
Profiles: "profiles",
Meta: "meta"
}
}
export * as Redis from "./db.ts";

View File

@@ -19,6 +19,7 @@ client.once(discord.Events.ClientReady, client => {
let shuttingDown = false;
Deno.addSignalListener('SIGINT', () => {
if (client.readyTimestamp == null) return;
if (shuttingDown) return;
shuttingDown = true;
log.n('Disconnecting from Discord');
@@ -26,8 +27,12 @@ Deno.addSignalListener('SIGINT', () => {
});
export function login() {
if (config?.discord?.token == Config.defaultConfig.discord?.token) {
log.i('Discord not configured, ignoring');
return;
}
log.i(`Creating Discord connection..`);
client.login(config?.discord.token);
client.login(config?.discord?.token);
}
export * as Discord from "./discord.ts";

View File

@@ -5,11 +5,11 @@ export enum ResultType {
NotFound
}
interface ConfigResult {
type ConfigResult = {
Status: ResultType,
Data: string | null
}
interface ConfigMResult {
type ConfigMResult = {
Status: ResultType,
Data: (string | null)[] | null
}

View File

@@ -2,8 +2,10 @@ import * as Log from "@proxnet/undead-logging";
import * as Config from "./config.ts";
// @ts-types = 'npm:@types/express'
import express from "express";
import { Redis } from "./db.ts";
import { Database } from "./db.ts";
import { APIUtils } from "./apiutils.ts";
import { Discord } from "./discord.ts";
import { User } from "./data/users.ts";
const log = new Log.default("Main");
@@ -39,14 +41,23 @@ app.use((rq: express.Request, rs: express.Response, nxt: express.NextFunction) =
nxt();
});
app.use('/', APIUtils.genericResponse(false, `${config?.public.serverName} - ${config?.public.motd}`));
app.get('/', APIUtils.genericResponse(false, `${config?.public.serverName} - ${config?.public.motd}`));
app.get('/debug', async (_rq, rs) => {
const user = await User.init({ username: "testuser123", password: "foopass123" });
log.i(String(user == null));
rs.sendStatus(200);
});
// content routes
const nameserverRouter = await import('./routes/nameserver.ts');
const apiRouter = await import('./routes/api.ts');
const userRouter = await import('./routes/user.ts');
app.use(nameserverRouter.route.path, nameserverRouter.route.router);
app.use(apiRouter.route.path, apiRouter.route.router);
app.use(userRouter.route.path, userRouter.route.router);
app.use((rq: express.Request, rs: express.Response) => {
log.e(`${APIUtils.getSrcIpDefault(rq)} 404 ${rq.method} ${rq.url.toString()}`);
@@ -55,8 +66,7 @@ app.use((rq: express.Request, rs: express.Response) => {
});
try {
log.i(`Connecting to Redis..`);
Redis.connectToRedis();
Database.connect();
} catch (err) {
log.e(`Cannot start: Redis could not be initialized. ${err}`);
Deno.exit(1);
@@ -81,4 +91,4 @@ try {
Deno.exit(1);
}
//Discord.login(); do not use for now
Discord.login();

View File

@@ -4,5 +4,5 @@ import { APIUtils } from "../apiutils.ts";
export const route = APIUtils.createRouter('/api');
route.router.use(VersionCheckRoute.router);
route.router.use(ConfigRoute.router);
route.router.use(VersionCheckRoute.path, VersionCheckRoute.router);
route.router.use(ConfigRoute.path, ConfigRoute.router);

View File

@@ -3,7 +3,7 @@ import { GameConfigs } from "../../data/config.ts";
export const route = APIUtils.createRouter('/config');
route.router.get('/v2', (rq, rs) => {
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,7 @@
import { APIUtils } from "../../apiutils.ts";
export const route = APIUtils.createRouter('/gameconfigs');
route.router.get('/v1/all', (_rq, rs) => {
rs.json([]);
});

View File

@@ -4,20 +4,29 @@ export const route = APIUtils.createRouter('/versioncheck');
const validVersion = '20191120';
enum VersionStatus {
UpdateRequired,
ValidForMenu,
ValidForPlay
}
type ValidVersionResponse = {
ValidVersion: boolean
VersionStatus: VersionStatus
}
route.router.get('/v3', (rq, rs) => {
route.router.get('/v4', (rq, rs) => {
const requestedVer = rq.query['v'];
if (typeof requestedVer !== 'string' || requestedVer !== validVersion) {
if (typeof requestedVer == 'undefined') {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, 'One or more query parameters were not found.'));
}
else if (requestedVer !== validVersion) {
const res: ValidVersionResponse = {
ValidVersion: false
VersionStatus: VersionStatus.UpdateRequired
}
rs.json(res);
} else {
const res: ValidVersionResponse = {
ValidVersion: true
VersionStatus: VersionStatus.ValidForPlay
}
rs.json(res);
}

View File

@@ -20,19 +20,18 @@ type NameserverHosts = {
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
Auth: `${protocol}://${config.web.nameserverHost}/auth`,
API: `${protocol}://${config.web.nameserverHost}`,
WWW: `${protocol}://${config.web.nameserverHost}`,
Notifications: `${protocol}://${config.web.nameserverHost}/notify`,
Images: `${protocol}://${config.web.nameserverHost}/img`,
CDN: `${protocol}://${config.web.nameserverHost}/cdn`,
Commerce: `${protocol}://${config.web.nameserverHost}/commerce`,
Matchmaking: `${protocol}://${config.web.nameserverHost}/match`,
Storage: `${protocol}://${config.web.nameserverHost}/storage`,
Chat: `${protocol}://${config.web.nameserverHost}/chat`,
Leaderboard: `${protocol}://${config.web.nameserverHost}/leaderboard`
}
route.router.get('*', (_rq, rs) => {

53
src/routes/user.ts Normal file
View File

@@ -0,0 +1,53 @@
import { APIUtils, getSrcIpDefault } from "../apiutils.ts";
// @ts-types = "npm:@types/express"
import express from "express";
import { User } from "../data/users.ts";
import Recaptcha from "../data/recaptcha.ts";
export const route = APIUtils.createRouter('/user');
type CreateUserBody = {
username: string,
password: string,
recaptcha: string
}
type CreatedUserResponse = {
uuid: string,
backupcode: string
}
const rateLimit = new APIUtils.RateLimiter(10, 1);
route.router.post('/create',
rateLimit.middle(),
express.json(),
APIUtils.checkBodyTypes<CreateUserBody>({ username: "test", password: "test", recaptcha: "test" }),
async (rq, rs) => {
const body = rq.body as CreateUserBody;
const recaptchaStatus = await Recaptcha.siteVerify(body.recaptcha, getSrcIpDefault(rq));
if (recaptchaStatus) {
const userinit = await User.init({ username: body.username, password: body.password });
if (userinit == null) {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, "Username is already taken"));
} else {
const res: CreatedUserResponse = {
uuid: userinit.user.getUuid(),
backupcode: userinit.backupcode
}
rs.json(APIUtils.genericResponseFormat(false, "User created successfully", res));
}
}
else {
rs.statusCode = 400;
rs.json(APIUtils.genericResponseFormat(true, "ReCAPTCHA error"));
}
}
);