Some checks failed
Galvanic Corrosion Cross-Compile / build (push) Failing after 38s
* Rewrite rooms backend, "RoomFactory" and "SubroomFactory"
- Used for modifying and fetching rooms
* Progression and reputation bulk endpoints
* Announcement endpoint temp
* OOBE is now the only initial pref key
- Will be removed in the future when cohortnux is implemented
* Misc minor fixes and clarifications
* Simplified namegen dictionary
- The previous one was generated with ChatGPT, hence the duplicated strings. I googled "random username generator" and borrowed a random result's generation dictionary.
* QuickPlay support with "initialRoom" in config (untested)
235 lines
8.9 KiB
TypeScript
235 lines
8.9 KiB
TypeScript
/* 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 * 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<ProfileTokenFormat>(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<Deno.NetAddr>) => {
|
|
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(); |