/* Galvanic Corrosion - Rec Room custom server for communities. 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 . */ import * as Log from "@proxnet/undead-logging"; import * as Config from "./config.ts"; import { Database } from "./db.ts"; import { APIUtils, ProfileTokenSchema } from "./apiutils.ts"; import { Discord } from "./discord.ts"; import { generateRandomString } from "./apiutils.ts"; // @ts-types = "npm:@types/express" import express from "express"; import { decode } from "@gz/jwt"; import UnifiedProfile, { ProfileTokenFormat } from "./data/profiles.ts"; import { SocketHandoff } from "./socket/handoff.ts"; import { SignalRSocketHandler } from "./socket/socket.ts"; import Rooms from "./data/content/rooms.ts"; import { GameConfigs } from "./data/config.ts"; const instanceId = generateRandomString(64); const log = new Log.default("Main"); log.i(`Starting Galvanic Corrosion..`); const config = Config.getConfig(); if (typeof config == "undefined") { log.e("Cannot start: Configuration is undefined"); Deno.exit(1); } if (config.auth.secret == Config.defaultConfig.auth.secret) { log.e(`Cannot start: Auth secret is default. Please change 'auth.secret' in 'config.json'`); Deno.exit(1); } if (config.public.serverId == Config.defaultConfig.public.serverId) { log.e(`Cannot start: Server ID is default. Please change 'public.serverId' in 'config.json'`); Deno.exit(1); } Log.MessageTypeVisibility.Network = config.logging.network; Log.MessageTypeVisibility.Debug = config.logging.debug; try { Database.connect(); } catch (err) { log.e(`Cannot start: Redis could not be initialized. ${err}`); Deno.exit(1); } const app = express(); app.disable("etag"); app.disable("x-powered-by"); app.use( (rq: express.Request, rs: express.Response, nxt: express.NextFunction) => { rs.setHeader("Instance", instanceId); rs.locals.reqId = generateRandomString(12); log.n(`${rs.locals.reqId} ${APIUtils.getSrcIpDefault(rq)} ${rq.method} ${rq.originalUrl}`); nxt(); }, ); app.get("/info", APIUtils.setCacheAllowed, (_rq, rs) => { rs.json({ name: config.public.serverName, id: config.public.serverId, motd: config.public.motd, patches: config.public.patches, }); }); // content routes const nameserverRouter = await import("./routes/nameserver.ts"); const apiRouter = await import("./routes/api.ts"); const userRouter = await import("./routes/user.ts"); const authRouter = await import("./routes/auth.ts"); const accountRouter = await import("./routes/account.ts"); const imgRouter = await import("./routes/img.ts"); const matchRouter = await import("./routes/match.ts"); const notifyRouter = await import("./socket/route.ts"); const cdnRouter = await import("./routes/cdn.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(authRouter.route.path, authRouter.route.router); app.use(accountRouter.route.path, accountRouter.route.router); app.use(imgRouter.route.path, imgRouter.route.router); app.use(matchRouter.route.path, matchRouter.route.router); app.use(notifyRouter.route.path, notifyRouter.route.router); app.use(cdnRouter.route.path, cdnRouter.route.router); app.use((rq: express.Request, rs: express.Response) => { log.e(`${rs.locals.reqId} ${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.")); }); if (!(await Rooms.generateBuiltinRooms())) log.i(`Generated built-in rooms`); try { type AuthResultBase = { valid: boolean } interface SuccessfulAuth extends AuthResultBase { token: ProfileTokenFormat, valid: true } interface FailedAuth extends AuthResultBase { valid: false } type AuthResult = FailedAuth | SuccessfulAuth; const authenticate = async (req: Request) => { const authHeader = req.headers.get('authorization'); if (!authHeader) return { valid: false } as AuthResult; //log.d(authHeader); const token = authHeader.split(", ")[1]; // Why is the header formatted like this? if (!token) return { valid: false } as AuthResult; const splitToken = token.split(' ')[1]; if (!splitToken) return { valid: false } as AuthResult; const decodedToken = await decode(splitToken, config.auth.secret, {algorithm: 'HS512'}); const schemaResult = ProfileTokenSchema.safeParse(decodedToken); if (!schemaResult.success) return { valid: false } as AuthResult; else return { token: decodedToken, valid: true } as AuthResult; } const port = config.web.api.port; const host = config.web.api.host; log.n(`Starting HTTP server on http://${host}:${port}`); const abort = new AbortController(); abort.signal.addEventListener('abort', () => { log.n("Closing all sockets"); const sockets = UnifiedProfile.getAllSockets(); for (const socket of sockets.values()) { socket.destroy(socket)(); } // I used the socket to destroy the socket }); // Galvanic WebSocket Deno.serve({port: config.web.socket.port, hostname: config.web.socket.host, signal: abort.signal, onListen: addr => { log.n(`Socket listening on http://${addr.hostname}:${addr.port}`); }}, async (req: Request, info: Deno.ServeHandlerInfo) => { const path = new URL(req.url).pathname; const upgrade = req.headers.get('Upgrade') === 'websocket'; log.n(`U:${upgrade}; ${info.remoteAddr.hostname}:${info.remoteAddr.port} ${req.method} ${path}`); if (path === '/negotiate' && req.method == 'POST') return new Response(JSON.stringify({})); if (!upgrade) return new Response(null, { status: 401 }); const authResult = await authenticate(req); if (authResult.valid) { // ID is given as "/notify/hub/v1?&id=pprhdSzJn" by the client. let handoff: SocketHandoff | undefined; if (req.url) { const pathParts = req.url.replace('v1', '').split('/'); const query = new URLSearchParams(pathParts[pathParts.length - 1]); const connectionId = query.get('id'); if (connectionId) handoff = SocketHandoff.find(connectionId); } if (handoff) handoff.complete(); const { socket, response } = Deno.upgradeWebSocket(req); new SignalRSocketHandler(socket, UnifiedProfile.get(authResult.token.sub)); return response; } else { log.e(`401 ${info.remoteAddr} ${req.method} ${req.url}`); return new Response(null, { status: 401 }); } }); const http = app.listen(config.web.api.port, config.web.api.host, async () => { log.n(`Web listening on http://${config.web.api.host}:${config.web.api.port}`); let shuttingDown = false; Deno.addSignalListener("SIGINT", () => { if (shuttingDown) return; shuttingDown = true; log.i(`Shutting down`); abort.abort(); // websockets http.close(); http.closeAllConnections(); }); Deno.addSignalListener("SIGINT", () => { for (const handoff of SocketHandoff.all()) handoff.complete(); }); if (!(await UnifiedProfile.existsByName("Coach"))) UnifiedProfile.create({ username: "Coach", id: 1 }); // create Coach id 1 if they do not exist if (!(await UnifiedProfile.existsByName("Server"))) UnifiedProfile.create({ username: "Server", id: 2 }); // create Server id 2 if they do not exist if (!(await GameConfigs.getGameConfig('splitTestSoftOverrides'))) GameConfigs.setGameConfig('splitTestSoftOverrides', ''); if (!(await GameConfigs.getGameConfig('splitTestHardOverrides'))) GameConfigs.setGameConfig('splitTestHardOverrides', ''); log.i('Startup done.'); }); http.on('error', err => { log.e(`HTTP error: ${err.stack}`); }); } catch (err) { log.e(`Cannot start: Network could not be initalized. ${err}`); Deno.exit(1); } Discord.login();