Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
/persist/
|
||||||
12
deno.json
Normal file
12
deno.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run -A --env-file --unstable-kv src/main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@hono/hono": "jsr:@hono/hono@^4.8.5",
|
||||||
|
"@hono/zod-validator": "jsr:@hono/zod-validator@^0.7.2",
|
||||||
|
"@proxnet/undead-logging": "jsr:@proxnet/undead-logging@^1.5.0",
|
||||||
|
"@std/assert": "jsr:@std/assert@1",
|
||||||
|
"zod": "npm:zod@^4.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
67
deno.lock
generated
Normal file
67
deno.lock
generated
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@hono/hono@^4.8.3": "4.8.5",
|
||||||
|
"jsr:@hono/hono@^4.8.5": "4.8.5",
|
||||||
|
"jsr:@hono/zod-validator@~0.7.2": "0.7.2",
|
||||||
|
"jsr:@proxnet/undead-logging@^1.5.0": "1.5.0",
|
||||||
|
"jsr:@std/assert@1": "1.0.13",
|
||||||
|
"jsr:@std/internal@^1.0.6": "1.0.9",
|
||||||
|
"npm:@types/node@*": "22.15.15",
|
||||||
|
"npm:chalk@^5.3.0": "5.4.1",
|
||||||
|
"npm:zod@^4.0.5": "4.0.5"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@hono/hono@4.8.5": {
|
||||||
|
"integrity": "78f72e532f378e379915a7e1ae7bd8a171b02324bd37b70877fd35375e8c2d6b"
|
||||||
|
},
|
||||||
|
"@hono/zod-validator@0.7.2": {
|
||||||
|
"integrity": "2c055aabbd9e349b32bd0761bed1efd31b5b47f2cb42cd6eef91186b6d5cf4e1",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@hono/hono@^4.8.3",
|
||||||
|
"npm:zod"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@proxnet/undead-logging@1.5.0": {
|
||||||
|
"integrity": "b358b3caf2dc17d91a840dfbdb85d3cf253909928810df501ac131dfbcb33c45",
|
||||||
|
"dependencies": [
|
||||||
|
"npm:chalk"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/assert@1.0.13": {
|
||||||
|
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/internal@1.0.9": {
|
||||||
|
"integrity": "bdfb97f83e4db7a13e8faab26fb1958d1b80cc64366501af78a0aee151696eb8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"@types/node@22.15.15": {
|
||||||
|
"integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
|
||||||
|
"dependencies": [
|
||||||
|
"undici-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"chalk@5.4.1": {
|
||||||
|
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="
|
||||||
|
},
|
||||||
|
"undici-types@6.21.0": {
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||||
|
},
|
||||||
|
"zod@4.0.5": {
|
||||||
|
"integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@hono/hono@^4.8.5",
|
||||||
|
"jsr:@hono/zod-validator@~0.7.2",
|
||||||
|
"jsr:@proxnet/undead-logging@^1.5.0",
|
||||||
|
"jsr:@std/assert@1",
|
||||||
|
"npm:zod@^4.0.5"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
89366
res/avatar.json
Normal file
89366
res/avatar.json
Normal file
File diff suppressed because it is too large
Load Diff
1254
res/equipment.json
Normal file
1254
res/equipment.json
Normal file
File diff suppressed because it is too large
Load Diff
3251
res/rooms.json
Normal file
3251
res/rooms.json
Normal file
File diff suppressed because it is too large
Load Diff
158
src/main.ts
Normal file
158
src/main.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import Logging, { LoggingConfiguration, LogTiming, TimeFormat } from "@proxnet/undead-logging";
|
||||||
|
import { getFullPathFromUrl, getSourceAddress } from "./util/net.ts";
|
||||||
|
import { createHonoRoute, routeImporter } from "./util/import.ts";
|
||||||
|
import SocketConsoleHandler from "./server/socket/console/socket.ts";
|
||||||
|
import Server from "./server/server.ts";
|
||||||
|
import Command from "./server/commands/command.ts";
|
||||||
|
import { ServerContentBase } from "./server/ContentBase.ts";
|
||||||
|
import z from "zod";
|
||||||
|
import { verify } from "@hono/hono/jwt";
|
||||||
|
import { type ProfileToken } from "./server/profiles/types/profile.ts";
|
||||||
|
import { SignalRSocketHandler } from "./server/socket/signalr/socket.ts";
|
||||||
|
import { PushNotificationId } from "./server/socket/signalr/types.ts";
|
||||||
|
|
||||||
|
LoggingConfiguration.resetTimeFormat = TimeFormat.Unix;
|
||||||
|
LoggingConfiguration.resetLogTiming = LogTiming.Microtask;
|
||||||
|
|
||||||
|
const log = new Logging("Main");
|
||||||
|
|
||||||
|
log.i(`wsi by zombieb`);
|
||||||
|
|
||||||
|
export function detailedLog(items: (string | number | boolean | null)[]) {
|
||||||
|
return items.filter(val => val !== null).join('\r\n ');
|
||||||
|
}
|
||||||
|
function formatHeader(headers: Headers, name: string) {
|
||||||
|
if (!headers.has(name)) return null;
|
||||||
|
return `${name}: ${headers.get(name)}`;
|
||||||
|
}
|
||||||
|
export const logBlacklist = [
|
||||||
|
'/favicon.ico'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const AppRoot = createHonoRoute('/');
|
||||||
|
|
||||||
|
await routeImporter(AppRoot.app, 'src/', [
|
||||||
|
'routes/root',
|
||||||
|
'routes/api',
|
||||||
|
'routes/auth',
|
||||||
|
'routes/accounts'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// deno-lint-ignore require-await
|
||||||
|
AppRoot.app.use('*', async c => {
|
||||||
|
if (!logBlacklist.includes(c.req.url)) log.e(detailedLog([c.get('srcAddr'),
|
||||||
|
`404 ${c.req.method} ${getFullPathFromUrl(new URL(c.req.url))}`
|
||||||
|
]));
|
||||||
|
c.res = new Response("Not Found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export const consoleSockets: Set<SocketConsoleHandler> = new Set();
|
||||||
|
const gameSockets: Set<SignalRSocketHandler> = new Set();
|
||||||
|
|
||||||
|
const onListen = async () => {
|
||||||
|
if (!(await Array.fromAsync(Deno.readDir('.'))).find(entry => entry.isDirectory && entry.name == 'persist'))
|
||||||
|
await Deno.mkdir('persist');
|
||||||
|
|
||||||
|
await Promise.all(Object.values(Server).map(base => ((base as ServerContentBase).kvInit ? (base as ServerContentBase).kvInit() : undefined)));
|
||||||
|
Object.values(Server).forEach(base => ((base as ServerContentBase).start ? (base as ServerContentBase).start() : undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Deno.serve({ hostname: "10.0.1.39", port: 13370, onListen: addr => {
|
||||||
|
log.n(`Listening info: ${JSON.stringify(addr)}`);
|
||||||
|
|
||||||
|
onListen();
|
||||||
|
}}, async (req, info) => {
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const srcAddr = getSourceAddress(req, info.remoteAddr);
|
||||||
|
const unauthRes = new Response("Unauthorized", { status: 401 });
|
||||||
|
|
||||||
|
if (url.pathname == '/notify/hub/v1/negotiate') {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
connectionId: "who_said_it",
|
||||||
|
availableTransports: [{transport:"WebSockets",transferFormats:["Text"]}]
|
||||||
|
}), { headers: { 'Content-Type': 'application/json' }});
|
||||||
|
}
|
||||||
|
if (req.headers.get('Connection')?.includes('Upgrade') && req.headers.get('Upgrade') === 'websocket') {
|
||||||
|
const isSignalR = req.headers.has('id');
|
||||||
|
if (isSignalR) {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (!authHeader) return unauthRes;
|
||||||
|
|
||||||
|
const splitHeader = authHeader.split(' ')[1];
|
||||||
|
if (!splitHeader) return unauthRes;
|
||||||
|
|
||||||
|
const secret = Deno.env.get('secret');
|
||||||
|
if (!secret) {
|
||||||
|
log.w(`No secret set!`);
|
||||||
|
return unauthRes;
|
||||||
|
}
|
||||||
|
const payload = (await verify(splitHeader, secret)) as ProfileToken;
|
||||||
|
|
||||||
|
const profile = await Server.Profiles.get(payload.sub);
|
||||||
|
if (!profile) return new Response("Internal Server Error (profile)", { status: 500 });;
|
||||||
|
const { response, socket } = Deno.upgradeWebSocket(req);
|
||||||
|
const handler = new SignalRSocketHandler(socket, profile);
|
||||||
|
gameSockets.add(handler);
|
||||||
|
socket.onclose = () => {
|
||||||
|
gameSockets.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
log.w(`Socket authentication error: ${(err as Error).stack}`);
|
||||||
|
return unauthRes;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const pass = url.searchParams.get('pass');
|
||||||
|
if (!pass) return unauthRes;
|
||||||
|
else if (pass !== Deno.env.get('CONSOLESECRET')) return unauthRes;
|
||||||
|
|
||||||
|
const { response, socket } = Deno.upgradeWebSocket(req);
|
||||||
|
const handler = new SocketConsoleHandler(socket, req, info);
|
||||||
|
consoleSockets.add(handler);
|
||||||
|
socket.onclose = () => {
|
||||||
|
consoleSockets.delete(handler);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logBlacklist.includes(url.pathname)) log.n(detailedLog([srcAddr,
|
||||||
|
`${req.method} ${getFullPathFromUrl(new URL(req.url))}`,
|
||||||
|
formatHeader(req.headers, 'Content-Type'),
|
||||||
|
formatHeader(req.headers, 'Connection'),
|
||||||
|
formatHeader(req.headers, 'User-Agent'),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return await AppRoot.app.fetch(req, { srcAddr });
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
Deno.addSignalListener('SIGINT', () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
else shuttingDown = true;
|
||||||
|
server.shutdown();
|
||||||
|
server.unref();
|
||||||
|
|
||||||
|
LoggingConfiguration.resetLogTiming = LogTiming.Sync;
|
||||||
|
log.i('Shutting down');
|
||||||
|
|
||||||
|
for (const socket of consoleSockets) socket.destroy();
|
||||||
|
for (const socket of gameSockets) socket.sendNotification(PushNotificationId.ModerationQuitGame);
|
||||||
|
});
|
||||||
|
|
||||||
|
Server.Commands.addRootCommand(new Command({
|
||||||
|
key: ['ping', 'latency'],
|
||||||
|
exec: (sent: number) => {
|
||||||
|
if (sent) return `${Date.now() - sent}ms`;
|
||||||
|
else return null;
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(Number)
|
||||||
|
]),
|
||||||
|
help: 'Get ping (in ms) to the server'
|
||||||
|
}));
|
||||||
7
src/routes/accounts/root.ts
Normal file
7
src/routes/accounts/root.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute, routeImporter } from "../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/accounts');
|
||||||
|
|
||||||
|
await routeImporter(route.app, 'src/routes/accounts/', [
|
||||||
|
'routes'
|
||||||
|
]);
|
||||||
38
src/routes/accounts/routes/account.ts
Normal file
38
src/routes/accounts/routes/account.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
import { authenticate } from "../../../util/api.ts";
|
||||||
|
import Server from "../../../server/server.ts";
|
||||||
|
import z from "zod";
|
||||||
|
import { typedZValidator } from "../../../util/validators.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/account');
|
||||||
|
|
||||||
|
const transformNumber = (arg: string, ctx: z.RefinementCtx<string>) => {
|
||||||
|
const int = parseInt(arg);
|
||||||
|
if (isNaN(int) || !Number.isSafeInteger(int)) ctx.addIssue('Number is not valid');
|
||||||
|
else return int;
|
||||||
|
}
|
||||||
|
const bulkAccountQuerySchema = z.object({
|
||||||
|
id: z.union([ z.string().transform(transformNumber), z.array(z.string().transform(transformNumber)) ])
|
||||||
|
});
|
||||||
|
route.app.get('/bulk', typedZValidator('query', bulkAccountQuerySchema), async c => {
|
||||||
|
const { id } = c.req.valid('query');
|
||||||
|
let ids: number[] = [];
|
||||||
|
|
||||||
|
if (!id) return c.json([]);
|
||||||
|
if (typeof id == 'number') ids = [id];
|
||||||
|
else ids = id.filter(val => typeof val == 'number');
|
||||||
|
|
||||||
|
return c.json((await Promise.all(ids.map(id => Server.Profiles.get(id))))
|
||||||
|
.filter(val => val !== null)
|
||||||
|
.map(prof => prof.export())
|
||||||
|
.filter(val => val !== null)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
route.app.use('*', authenticate);
|
||||||
|
|
||||||
|
route.app.get('/me', c => {
|
||||||
|
const profile = c.get('profile');
|
||||||
|
|
||||||
|
return c.json(profile.selfExport());
|
||||||
|
});
|
||||||
7
src/routes/api/root.ts
Normal file
7
src/routes/api/root.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute, routeImporter } from "../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/api');
|
||||||
|
|
||||||
|
await routeImporter(route.app, 'src/routes/api/', [
|
||||||
|
'routes'
|
||||||
|
]);
|
||||||
7
src/routes/api/routes/avatar.ts
Normal file
7
src/routes/api/routes/avatar.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/avatar");
|
||||||
|
|
||||||
|
route.app.get('/v1/defaultunlocked', c => {
|
||||||
|
return c.json([]);
|
||||||
|
});
|
||||||
7
src/routes/api/routes/config.ts
Normal file
7
src/routes/api/routes/config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/config");
|
||||||
|
|
||||||
|
route.app.get('/v1/amplitude', c => {
|
||||||
|
return c.json({AmplitudeKey: ""});
|
||||||
|
});
|
||||||
9
src/routes/api/routes/gameconfigs.ts
Normal file
9
src/routes/api/routes/gameconfigs.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Server from "../../../server/server.ts";
|
||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/gameconfigs');
|
||||||
|
|
||||||
|
route.app.get('/v1/all', async c => {
|
||||||
|
const configs = await Server.GameConfigs.getAllGameConfigs();
|
||||||
|
return c.json(configs);
|
||||||
|
});
|
||||||
6
src/routes/api/routes/gamesight.ts
Normal file
6
src/routes/api/routes/gamesight.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { successResponse } from "../../../util/api.ts";
|
||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/gamesight');
|
||||||
|
|
||||||
|
route.app.post('/event', successResponse(true, ""));
|
||||||
29
src/routes/api/routes/versioncheck.ts
Normal file
29
src/routes/api/routes/versioncheck.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
import { typedZValidator } from "../../../util/validators.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/versioncheck");
|
||||||
|
|
||||||
|
const versionCheckSchema = z.object({
|
||||||
|
v: z.string(),
|
||||||
|
p: z.string().transform(Number),
|
||||||
|
});
|
||||||
|
|
||||||
|
enum VersionStatus {
|
||||||
|
ValidForPlay,
|
||||||
|
ValidForMenu,
|
||||||
|
UpdateRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gameVerString = '20220118';
|
||||||
|
|
||||||
|
route.app.get('/v4', typedZValidator('query', versionCheckSchema), c => {
|
||||||
|
const { v, p } = c.req.valid('query');
|
||||||
|
|
||||||
|
if (v === gameVerString && p == 0) return c.json({
|
||||||
|
VersionStatus: VersionStatus.ValidForPlay
|
||||||
|
});
|
||||||
|
else return c.json({
|
||||||
|
VersionStatus: VersionStatus.UpdateRequired
|
||||||
|
});
|
||||||
|
});
|
||||||
7
src/routes/auth/root.ts
Normal file
7
src/routes/auth/root.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute, routeImporter } from "../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/auth');
|
||||||
|
|
||||||
|
await routeImporter(route.app, 'src/routes/auth/', [
|
||||||
|
'routes'
|
||||||
|
]);
|
||||||
18
src/routes/auth/routes/cachedlogin.ts
Normal file
18
src/routes/auth/routes/cachedlogin.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
import { transformStringToEnum, typedZValidator } from "../../../util/validators.ts";
|
||||||
|
import { PlatformType } from "../../../server/platforms/base.ts";
|
||||||
|
import Server from "../../../server/server.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/cachedlogin");
|
||||||
|
|
||||||
|
const cachedLoginFetchParamSchema = z.object({
|
||||||
|
platformType: z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
|
||||||
|
platformId: z.string().min(4)
|
||||||
|
});
|
||||||
|
|
||||||
|
route.app.get('/forplatformid/:platformType/:platformId', typedZValidator('param', cachedLoginFetchParamSchema), async c => {
|
||||||
|
const { platformType, platformId } = c.req.valid('param');
|
||||||
|
|
||||||
|
return c.json((await Server.Platforms.getCachedLogins(platformType || PlatformType.Steam, platformId, true)) || []);
|
||||||
|
});
|
||||||
128
src/routes/auth/routes/connect.ts
Normal file
128
src/routes/auth/routes/connect.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
import z from "zod";
|
||||||
|
import { transformStringToEnum, typedZValidator } from "../../../util/validators.ts";
|
||||||
|
import { DeviceClass, PlatformType, steamAuthTicketSchema } from "../../../server/platforms/base.ts";
|
||||||
|
import { gameVerString } from "../../api/routes/versioncheck.ts";
|
||||||
|
import Steam from "../../../util/steam/steam.ts";
|
||||||
|
import { SteamAuthResult } from "../../../util/steam/SteamAuthTypes.ts";
|
||||||
|
import Server from "../../../server/server.ts";
|
||||||
|
import Logging from "@proxnet/undead-logging";
|
||||||
|
import { verify } from "@hono/hono/jwt";
|
||||||
|
|
||||||
|
const log = new Logging("ConnectRouteDebug");
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/connect");
|
||||||
|
|
||||||
|
const authBodyBaseSchema = z.object({
|
||||||
|
client_id: z.literal("recroom"),
|
||||||
|
platform: z.string().transform(Number).transform((arg, ctx) => { // we only support steam right now
|
||||||
|
if (arg !== PlatformType.Steam) ctx.addIssue("platform was not Steam");
|
||||||
|
else return PlatformType.Steam;
|
||||||
|
}),
|
||||||
|
platform_id: z.string().min(4),
|
||||||
|
device_id: z.string().min(4),
|
||||||
|
device_class: z.string().transform(transformStringToEnum<DeviceClass>(DeviceClass)),
|
||||||
|
time: z.string().transform(Date),
|
||||||
|
ver: z.literal(gameVerString),
|
||||||
|
build_key: z.string().min(4),
|
||||||
|
asid: z.string().transform(Number),
|
||||||
|
eac_challenge: z.literal("who said it"),
|
||||||
|
eac_response: z.literal("who_said_it"),
|
||||||
|
platform_auth: z.string().transform((arg, ctx) => {
|
||||||
|
try {
|
||||||
|
const parsed = steamAuthTicketSchema.safeParse(JSON.parse(arg))
|
||||||
|
if (parsed.success) return parsed.data.Ticket;
|
||||||
|
else ctx.addIssue("Steam Auth Ticket could not be parsed")
|
||||||
|
} catch {
|
||||||
|
ctx.addIssue("Steam Auth Ticket could not be parsed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAccountGrantSchema = authBodyBaseSchema.extend({
|
||||||
|
grant_type: z.literal("create_account")
|
||||||
|
});
|
||||||
|
const cachedLoginGrantSchema = authBodyBaseSchema.extend({
|
||||||
|
grant_type: z.literal('cached_login'),
|
||||||
|
account_id: z.string().transform(Number),
|
||||||
|
});
|
||||||
|
const refreshTokenGrantSchema = authBodyBaseSchema.extend({
|
||||||
|
grant_type: z.literal('refresh_token'),
|
||||||
|
refresh_token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenGrantSchema = z.discriminatedUnion('grant_type', [
|
||||||
|
createAccountGrantSchema,
|
||||||
|
cachedLoginGrantSchema,
|
||||||
|
refreshTokenGrantSchema
|
||||||
|
]);
|
||||||
|
|
||||||
|
enum TokenRequestError {
|
||||||
|
InvalidRequest = "invalid_request",
|
||||||
|
InvalidGrant = "invalid_grant",
|
||||||
|
InvalidClient = "invalid_client",
|
||||||
|
InvalidUsernameOrPassword = "invalid_username_or_password",
|
||||||
|
InvalidTime = "invalid time",
|
||||||
|
InvalidPlatform = "invalid platform",
|
||||||
|
AccessDenied = "access_denied",
|
||||||
|
SlowDown = "slow_down",
|
||||||
|
PlatformVerificationFailed = "platform verification failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
route.app.post('/token', typedZValidator('form', tokenGrantSchema), async c => {
|
||||||
|
function error(error?: TokenRequestError, desc?: string) {
|
||||||
|
return c.json({ error, error_description: desc });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = c.req.valid('form');
|
||||||
|
if (typeof form.platform_auth == 'undefined' || typeof form.platform == 'undefined') return error(TokenRequestError.InvalidPlatform);
|
||||||
|
|
||||||
|
const { valid } = await Steam.AuthenticateUserTicket(form.platform_auth, form.platform_id);
|
||||||
|
if (valid == SteamAuthResult.Failure) return error(TokenRequestError.PlatformVerificationFailed);
|
||||||
|
|
||||||
|
if (Math.abs(Date.now() - new Date(form.time).getTime()) > 3_600_000) return error(TokenRequestError.InvalidTime);
|
||||||
|
|
||||||
|
const logins = await Server.Platforms.getCachedLogins(form.platform, form.platform_id, false);
|
||||||
|
if (form.grant_type == 'create_account' && logins && logins.length > 0) return error(TokenRequestError.InvalidRequest);
|
||||||
|
else if (form.grant_type == 'create_account') {
|
||||||
|
const profile = await Server.Profiles.create();
|
||||||
|
if (!profile) return error(TokenRequestError.AccessDenied);
|
||||||
|
await Server.Platforms.addCachedLogin(form.platform, form.platform_id, profile?.getId());
|
||||||
|
|
||||||
|
const token = await Server.Platforms.getToken(profile.getId(), await profile.getRole() || 'user');
|
||||||
|
return c.json({
|
||||||
|
access_token: token,
|
||||||
|
refresh_token: token,
|
||||||
|
key: "aHVo"
|
||||||
|
});
|
||||||
|
} else if (form.grant_type == 'refresh_token') {
|
||||||
|
const secret = Deno.env.get('SECRET');
|
||||||
|
if (!secret) {
|
||||||
|
log.w(`Secret not set!`);
|
||||||
|
return error(TokenRequestError.InvalidRequest);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await verify(form.refresh_token, secret);
|
||||||
|
return c.json({
|
||||||
|
access_token: form.refresh_token,
|
||||||
|
refresh_token: form.refresh_token,
|
||||||
|
key: "aHVo"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.w(`Authentication error (token req): ${(err as Error).stack}`);
|
||||||
|
return error(TokenRequestError.InvalidRequest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logins && logins.find(login => login.accountId === form.account_id)) {
|
||||||
|
const profile = await Server.Profiles.get(form.account_id);
|
||||||
|
if (!profile) return error(TokenRequestError.InvalidRequest, "No such profile");
|
||||||
|
const token = await Server.Platforms.getToken(profile.getId(), await profile.getRole() || 'user');
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
access_token: token,
|
||||||
|
refresh_token: token,
|
||||||
|
key: "aHVo"
|
||||||
|
});
|
||||||
|
} else return error(TokenRequestError.InvalidRequest, "No such profile");
|
||||||
|
});
|
||||||
7
src/routes/auth/routes/eac.ts
Normal file
7
src/routes/auth/routes/eac.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createHonoRoute } from "../../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute("/eac");
|
||||||
|
|
||||||
|
route.app.get('/challenge', c => {
|
||||||
|
return c.text(`"who said it"`);
|
||||||
|
});
|
||||||
31
src/routes/root/root.ts
Normal file
31
src/routes/root/root.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createHonoRoute } from "../../util/import.ts";
|
||||||
|
|
||||||
|
export const route = createHonoRoute('/');
|
||||||
|
|
||||||
|
route.app.get('/', async (c, next) => {
|
||||||
|
if (c.req.query('v') == '2') return c.json({
|
||||||
|
Accounts: "https://wsi.proxnet.dev/accounts",
|
||||||
|
API: "https://wsi.proxnet.dev/",
|
||||||
|
Auth: "https://wsi.proxnet.dev/auth",
|
||||||
|
BugReporting: "https://wsi.proxnet.dev/bugs",
|
||||||
|
CDN: "https://wsi.proxnet.dev/cdn",
|
||||||
|
Chat: "https://wsi.proxnet.dev/chat",
|
||||||
|
Clubs: "https://wsi.proxnet.dev/clubs",
|
||||||
|
Commerce: "https://wsi.proxnet.dev/commerce",
|
||||||
|
DataCollection: "https://wsi.proxnet.dev/datacol",
|
||||||
|
Discovery: "https://wsi.proxnet.dev/disc",
|
||||||
|
Images: "https://wsi.proxnet.dev/img",
|
||||||
|
Leaderboard: "https://wsi.proxnet.dev/leaderboard",
|
||||||
|
Link: "https://wsi.proxnet.dev/link",
|
||||||
|
Matchmaking: "https://wsi.proxnet.dev/match",
|
||||||
|
Moderation: "https://wsi.proxnet.dev/mod",
|
||||||
|
Notifications: "https://wsi.proxnet.dev/notify",
|
||||||
|
PlatformNotifications: "https://wsi.proxnet.dev/platnotify",
|
||||||
|
PlayerSettings: "https://wsi.proxnet.dev/plsettings",
|
||||||
|
RoomComments: "https://wsi.proxnet.dev/roomcomments",
|
||||||
|
Rooms: "https://wsi.proxnet.dev/rooms",
|
||||||
|
Storage: "https://wsi.proxnet.dev/storage",
|
||||||
|
WWW: "https://wsi.proxnet.dev/www",
|
||||||
|
});
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
26
src/server/ContentBase.ts
Normal file
26
src/server/ContentBase.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { type ServerBase } from "./server.ts";
|
||||||
|
import KV from "./persistence/kv.ts";
|
||||||
|
|
||||||
|
export class ServerContentBase {
|
||||||
|
protected server: ServerBase;
|
||||||
|
protected kv: KV;
|
||||||
|
|
||||||
|
constructor(server: ServerBase, id: string, kv?: boolean) {
|
||||||
|
this.server = server;
|
||||||
|
this.kv = new KV(id, kv);
|
||||||
|
}
|
||||||
|
|
||||||
|
async kvInit() {
|
||||||
|
await this.kv.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event method - ran when server starts (listens on address)
|
||||||
|
*
|
||||||
|
* Override me!
|
||||||
|
*/
|
||||||
|
start() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
5
src/server/avatars/base.ts
Normal file
5
src/server/avatars/base.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { ServerContentBase } from "../ContentBase.ts";
|
||||||
|
|
||||||
|
export class AvatarContentBase extends ServerContentBase {
|
||||||
|
|
||||||
|
}
|
||||||
19
src/server/avatars/types.ts
Normal file
19
src/server/avatars/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
enum _OutfitType {
|
||||||
|
None = -1,
|
||||||
|
Hat,
|
||||||
|
Hair = 2,
|
||||||
|
Ear,
|
||||||
|
Eye = 10,
|
||||||
|
Beard = 20,
|
||||||
|
Shoulder = 100,
|
||||||
|
Shirt,
|
||||||
|
Waist,
|
||||||
|
Neck,
|
||||||
|
TeamJersey,
|
||||||
|
Wrist = 200,
|
||||||
|
TeamWrist = 203
|
||||||
|
}
|
||||||
|
|
||||||
|
// figure out the order in which ids go into AvatarItemDesc
|
||||||
|
// then create a function in `base.ts` (next to this file) that can turn enums corresponding to avatar items into a full AvatarItemDesc string
|
||||||
|
// - that may require codegen. i can probably do it though. i know you can.
|
||||||
23
src/server/baseevent.ts
Normal file
23
src/server/baseevent.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type Callback<T> = (event: T) => void;
|
||||||
|
|
||||||
|
export class EventManager<Events extends { [K in keyof Events]: unknown }> {
|
||||||
|
#listeners: {
|
||||||
|
[K in keyof Events]?: Set<Callback<Events[K]>>
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
on<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
|
||||||
|
if (!this.#listeners[eventName])
|
||||||
|
this.#listeners[eventName] = new Set();
|
||||||
|
this.#listeners[eventName]!.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
|
||||||
|
this.#listeners[eventName]?.delete(callback);
|
||||||
|
if (this.#listeners[eventName]?.size === 0)
|
||||||
|
delete this.#listeners[eventName];
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<K extends keyof Events>(eventName: K, payload: Events[K]): void {
|
||||||
|
this.#listeners[eventName]?.forEach((callback) => callback(payload));
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/server/commands/cmdtypes.ts
Normal file
2
src/server/commands/cmdtypes.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export type CommandExec = (...args: any[]) => unknown | Promise<unknown>;
|
||||||
58
src/server/commands/command.ts
Normal file
58
src/server/commands/command.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { CommandExec } from "./cmdtypes.ts";
|
||||||
|
|
||||||
|
export interface CommandOptions {
|
||||||
|
key: string[],
|
||||||
|
subcommands?: Command[],
|
||||||
|
exec?: CommandExec,
|
||||||
|
zod?: z.ZodTuple,
|
||||||
|
help?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Command {
|
||||||
|
|
||||||
|
subCmds: Command[];
|
||||||
|
key: string[];
|
||||||
|
exec: CommandExec | null;
|
||||||
|
validate: z.ZodTuple | null;
|
||||||
|
help: string | null;
|
||||||
|
#argsLength: number;
|
||||||
|
|
||||||
|
constructor(options: CommandOptions) {
|
||||||
|
this.subCmds = options.subcommands || [];
|
||||||
|
this.key = options.key;
|
||||||
|
this.exec = options.exec || null;
|
||||||
|
this.validate = options.zod || null;
|
||||||
|
this.help = options.help || null;
|
||||||
|
this.#argsLength = options.zod ? options.zod.def.items.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getKey() {
|
||||||
|
return this.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(...args: unknown[]): unknown | Promise<unknown> {
|
||||||
|
const root = args[0] as string | undefined;
|
||||||
|
if (!root && this.exec)
|
||||||
|
return this.exec(...args);
|
||||||
|
else if (!root) return new Error('No execution target for this root');
|
||||||
|
|
||||||
|
const cmd = this.subCmds.find(cmd => cmd.getKey().includes(root));
|
||||||
|
if (cmd) {
|
||||||
|
const newArgs = args.slice(1);
|
||||||
|
if (cmd.#argsLength && newArgs.length !== cmd.#argsLength && cmd.help) return new Error(cmd.help);
|
||||||
|
if (cmd.validate) {
|
||||||
|
const res = cmd.validate.safeParse(newArgs);
|
||||||
|
if (res.success) return cmd.dispatch(...res.data);
|
||||||
|
else if (cmd.help) return new Error(cmd.help);
|
||||||
|
else if (cmd.#argsLength) return new Error(`'${root}' validation error: expected ${cmd.#argsLength} args, got ${newArgs.length}`);
|
||||||
|
else return new Error(`'${root}' validation error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.exec)
|
||||||
|
return this.exec(...args);
|
||||||
|
else return new Error(`'${root}': Subcommand not found (args: "${args.join(" ")}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
39
src/server/commands/commands.ts
Normal file
39
src/server/commands/commands.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ServerContentBase } from "../ContentBase.ts";
|
||||||
|
import type Command from "./command.ts";
|
||||||
|
|
||||||
|
export class CommandsBase extends ServerContentBase {
|
||||||
|
|
||||||
|
#cmds: Set<Command> = new Set();
|
||||||
|
|
||||||
|
addRootCommand(cmd: Command) {
|
||||||
|
this.#cmds.add(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRootCommandByKey(key: string) {
|
||||||
|
for (const cmd of this.#cmds) if (cmd.getKey().includes(key))
|
||||||
|
this.#cmds.delete(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRootCommand(cmd: Command) {
|
||||||
|
this.#cmds.delete(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispatch(...args: string[]): Promise<unknown> {
|
||||||
|
const root = args[0];
|
||||||
|
if (typeof root !== 'string') return new Error("Root command must be of primitive type 'string'");
|
||||||
|
else {
|
||||||
|
const cmd = this.#cmds.values().toArray().find(cmd => cmd.getKey().includes(root));
|
||||||
|
if (cmd) {
|
||||||
|
const val = await cmd.dispatch(...args.slice(1));
|
||||||
|
if (val == null) return "null";
|
||||||
|
else if (typeof val == 'string') return `"${val}"`;
|
||||||
|
else if (typeof val == 'undefined') return "undefined";
|
||||||
|
else if (val instanceof Error) return val;
|
||||||
|
else if (typeof val == 'object') return JSON.stringify(val);
|
||||||
|
else return String(val);
|
||||||
|
}
|
||||||
|
else return new Error(`'${root.trim()}': Command not found (args: "${args.join(' ')}")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
81
src/server/gameconfigs/base.ts
Normal file
81
src/server/gameconfigs/base.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import Command from "../commands/command.ts";
|
||||||
|
import { ServerContentBase } from "../ContentBase.ts";
|
||||||
|
|
||||||
|
export default class GameConfigsBase extends ServerContentBase {
|
||||||
|
|
||||||
|
#kvKey = 'gameconfigs';
|
||||||
|
|
||||||
|
#usageError = new Error("Usage: <key: string> [<value: string>]");
|
||||||
|
|
||||||
|
async getGameConfig(key: string) {
|
||||||
|
return (await this.kv.getKv().get<string>([this.#kvKey, key])).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGameConfig(key: string, value: string) {
|
||||||
|
await this.kv.getKv().set([this.#kvKey, key], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delGameConfig(key: string) {
|
||||||
|
await this.kv.getKv().delete([this.#kvKey, key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllGameConfigs() {
|
||||||
|
return (await Array.fromAsync(
|
||||||
|
this.kv.getKv().list<string>({ prefix: [this.#kvKey] })
|
||||||
|
)).map(val =>
|
||||||
|
({ Key: val.key[1] as string, Value: val.value })
|
||||||
|
).filter(gc => gc.Key && gc.Value); // ensure both the key and value exist
|
||||||
|
}
|
||||||
|
|
||||||
|
override start(): void {
|
||||||
|
this.server.Commands.addRootCommand(new Command({
|
||||||
|
key: ['gameconfigs', 'gameconfig', 'gc'],
|
||||||
|
subcommands: [
|
||||||
|
new Command({
|
||||||
|
key: ['get', 'g'],
|
||||||
|
exec: async (key: string) => {
|
||||||
|
if (!key) return this.#usageError;
|
||||||
|
return await this.getGameConfig(key);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string()
|
||||||
|
]),
|
||||||
|
help: 'Get a saved GameConfig: <key: string>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['set', 's'],
|
||||||
|
exec: async (key: string, value: string) => {
|
||||||
|
if (!key || !value) return this.#usageError;
|
||||||
|
return await this.setGameConfig(key, value);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string(),
|
||||||
|
z.string()
|
||||||
|
]),
|
||||||
|
help: 'Set a new GameConfig: <key: string>, <value: string>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['del', 'rem', 'd', 'remove'],
|
||||||
|
exec: async (key: string) => {
|
||||||
|
if (!key) return this.#usageError;
|
||||||
|
return await this.delGameConfig(key);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string()
|
||||||
|
]),
|
||||||
|
help: 'Remove a saved GameConfig: <key: string>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['list', 'l', 'getall'],
|
||||||
|
exec: async () => {
|
||||||
|
const configs = await this.getAllGameConfigs();
|
||||||
|
return configs;
|
||||||
|
},
|
||||||
|
help: 'List all saved GameConfigs'
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
24
src/server/persistence/kv.ts
Normal file
24
src/server/persistence/kv.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
class KV {
|
||||||
|
|
||||||
|
#kv: Deno.Kv | null = null;
|
||||||
|
#initKv: boolean
|
||||||
|
|
||||||
|
#id: string;
|
||||||
|
|
||||||
|
constructor(id: string, kv?: boolean) {
|
||||||
|
this.#id = id;
|
||||||
|
this.#initKv = kv ? kv : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.#initKv) this.#kv = await Deno.openKv(`./persist/${this.#id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getKv() {
|
||||||
|
if (this.#kv) return this.#kv;
|
||||||
|
else throw new Error("KV not yet open");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KV;
|
||||||
182
src/server/platforms/base.ts
Normal file
182
src/server/platforms/base.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import Command from "../commands/command.ts";
|
||||||
|
import { ServerContentBase } from "../ContentBase.ts";
|
||||||
|
import { transformStringToEnum } from "../../util/validators.ts";
|
||||||
|
import { sign } from "@hono/hono/jwt";
|
||||||
|
|
||||||
|
export enum PlatformType {
|
||||||
|
All = -1,
|
||||||
|
Steam,
|
||||||
|
Oculus,
|
||||||
|
PlayStation,
|
||||||
|
Xbox,
|
||||||
|
WindowsPlatformless,
|
||||||
|
IOS,
|
||||||
|
GooglePlay
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeviceClass {
|
||||||
|
Unknown,
|
||||||
|
VR,
|
||||||
|
Screen,
|
||||||
|
Mobile,
|
||||||
|
VRLow,
|
||||||
|
Quest2
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface DbCachedLogin {
|
||||||
|
accountId: number,
|
||||||
|
lastLoginTime: Date,
|
||||||
|
requirePassword: boolean
|
||||||
|
}
|
||||||
|
export interface CachedLogin extends DbCachedLogin {
|
||||||
|
platformId: string
|
||||||
|
platform: PlatformType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const steamAuthTicketSchema = z.object({
|
||||||
|
Ticket: z.string().min(256),
|
||||||
|
AppId: z.literal("471710")
|
||||||
|
});
|
||||||
|
|
||||||
|
export enum ProfileRole {
|
||||||
|
Developer = "developer",
|
||||||
|
Moderator = "moderator",
|
||||||
|
Screenshare = "screenshare",
|
||||||
|
User = "user"
|
||||||
|
}
|
||||||
|
interface TokenFormat {
|
||||||
|
iss: string,
|
||||||
|
exp: number,
|
||||||
|
sub: number,
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlatformsManager extends ServerContentBase {
|
||||||
|
|
||||||
|
static platformsKey = "platforms";
|
||||||
|
|
||||||
|
#constructPlatformKey(...keys: (string | number | undefined)[]) {
|
||||||
|
return [PlatformsManager.platformsKey, ...keys.filter(val => typeof val == 'string')];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(accountId: number, role: string) {
|
||||||
|
const secret = Deno.env.get('SECRET');
|
||||||
|
if (!secret) throw new Error("No SECRET in env. Did you forget to set it?");
|
||||||
|
|
||||||
|
const token: TokenFormat = {
|
||||||
|
sub: accountId,
|
||||||
|
role,
|
||||||
|
iss: "https://wsi.proxnet.dev/auth/",
|
||||||
|
exp: Math.round(Date.now() / 1000) + 21_600
|
||||||
|
}
|
||||||
|
return await sign(JSON.parse(JSON.stringify(token)), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLastLoginTime(platform: PlatformType, platformId: string, accountId: number) {
|
||||||
|
const key = this.#constructPlatformKey(platform, platformId);
|
||||||
|
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
|
||||||
|
|
||||||
|
if (set.value) {
|
||||||
|
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
existing.lastLoginTime = new Date();
|
||||||
|
await this.kv.getKv().set(key, set.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCachedLogin(platform: PlatformType, platformId: string, accountId: number) {
|
||||||
|
const key = this.#constructPlatformKey(platform, platformId);
|
||||||
|
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
|
||||||
|
|
||||||
|
if (set.value) {
|
||||||
|
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
|
||||||
|
if (!existing) {
|
||||||
|
set.value.add({ accountId, lastLoginTime: new Date(), requirePassword: false });
|
||||||
|
await this.kv.getKv().set(key, set.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return set.value.values().toArray();
|
||||||
|
} else {
|
||||||
|
const newSet = new Set<DbCachedLogin>([{ accountId, lastLoginTime: new Date(), requirePassword: false }]);
|
||||||
|
await this.kv.getKv().set(key, newSet);
|
||||||
|
return newSet.values().toArray();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCachedLogins(platform: PlatformType, platformId: string, format: true): Promise<CachedLogin[] | null>
|
||||||
|
async getCachedLogins(platform: PlatformType, platformId: string, format: false): Promise<DbCachedLogin[] | null>
|
||||||
|
async getCachedLogins(platform: PlatformType, platformId: string, format?: boolean) {
|
||||||
|
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(this.#constructPlatformKey(platform, platformId));
|
||||||
|
if (set.value && format) return set.value.values().toArray().map(val => ({
|
||||||
|
platform,
|
||||||
|
platformId,
|
||||||
|
accountId: val.accountId,
|
||||||
|
lastLoginTime: val.lastLoginTime,
|
||||||
|
requirePassword: val.requirePassword
|
||||||
|
} as CachedLogin));
|
||||||
|
else if (set.value) return set.value.values().toArray();
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCachedLogin(platform: PlatformType, platformId: string, accountId: number) {
|
||||||
|
const key = this.#constructPlatformKey(platform, platformId);
|
||||||
|
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
|
||||||
|
|
||||||
|
if (set.value) {
|
||||||
|
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
|
||||||
|
if (existing) {
|
||||||
|
set.value.delete(existing);
|
||||||
|
await this.kv.getKv().set(key, set.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return set.value.values().toArray();
|
||||||
|
} else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
override start() {
|
||||||
|
this.server.Commands.addRootCommand(new Command({
|
||||||
|
key: ['platforms', 'pm', 'platformmanager', 'platformanager'],
|
||||||
|
subcommands: [
|
||||||
|
new Command({
|
||||||
|
key: ['get', 'g', 'list', 'l'],
|
||||||
|
exec: async (type: PlatformType, platformId: string) => {
|
||||||
|
return await this.getCachedLogins(type, platformId, false);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
|
||||||
|
z.string().min(4)
|
||||||
|
]),
|
||||||
|
help: 'List all cachedlogins for platformId: <type: PlatformType, platformId: string>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['set', 's', 'add', 'a'],
|
||||||
|
exec: async (type: PlatformType, platformId: string, accountId: number) => {
|
||||||
|
return await this.addCachedLogin(type, platformId, accountId);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
|
||||||
|
z.string(),
|
||||||
|
z.string().transform(Number)
|
||||||
|
]),
|
||||||
|
help: 'Add a cachedlogin for platformId: <type: PlatformType>, <platformId: string>, <accountId: number>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['del', 'd', 'rem', 'remove', 'r'],
|
||||||
|
exec: async (type: PlatformType, platformId: string, accountId: number) => {
|
||||||
|
return await this.deleteCachedLogin(type, platformId, accountId);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
|
||||||
|
z.string(),
|
||||||
|
z.string().transform(Number)
|
||||||
|
]),
|
||||||
|
help: 'Remove a cachedlogin for platformId: <type: PlatformType>, <platformId: string>, <accountId: number>'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
5
src/server/profiles/content/base.ts
Normal file
5
src/server/profiles/content/base.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
class ProfileContentManager {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileContentManager;
|
||||||
24
src/server/profiles/dict.ts
Normal file
24
src/server/profiles/dict.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const NameDictionary = {
|
||||||
|
Adjectives: [
|
||||||
|
"Alpha", "Zen", "Ruby", "Pixel", "Captain",
|
||||||
|
"Luna", "Quantum", "Emerald", "Serene", "Sushi",
|
||||||
|
"Mountain", "Phoenix", "Electric", "Songbird", "Tech",
|
||||||
|
"Silver", "Midnight", "Tango", "Cosmic", "Jazz",
|
||||||
|
"Velvet", "Neon", "Ghostly", "Ballet", "Delta",
|
||||||
|
"Echo", "Solar", "Pirate", "Harmonic",
|
||||||
|
"Cyber", "Melody", "Quasar", "Crimson", "Enigma",
|
||||||
|
"Stardust", "Techno", "Lunar", "Rogue", "Dream"
|
||||||
|
],
|
||||||
|
Nouns: [
|
||||||
|
"Wolf", "Master", "Red", "Pirate", "Adventure",
|
||||||
|
"Lovegood", "Coder", "Enigma", "Seeker", "Samurai",
|
||||||
|
"Mover", "Fire", "Echo", "Soul", "Titan",
|
||||||
|
"Shadow", "Mystic", "Tornado", "Crafter", "Journey",
|
||||||
|
"Vortex", "Nebula", "Gazer", "Blossom", "Dynamo",
|
||||||
|
"Eagle", "Symphony", "Willow", "Pioneer", "Hawk",
|
||||||
|
"Scribe", "Mistress", "Quest", "Comet", "Explorer",
|
||||||
|
"Strider", "Trance", "Lullaby", "Dancer"
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NameDictionary;
|
||||||
5
src/server/profiles/events/ProfileUpdate.ts
Normal file
5
src/server/profiles/events/ProfileUpdate.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type Profile from "../profile.ts";
|
||||||
|
|
||||||
|
export interface ProfileUpdateEvent {
|
||||||
|
profile: Profile
|
||||||
|
}
|
||||||
7
src/server/profiles/events/RoleUpdate.ts
Normal file
7
src/server/profiles/events/RoleUpdate.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { type ProfileRole } from "../../platforms/base.ts";
|
||||||
|
import type Profile from "../profile.ts";
|
||||||
|
|
||||||
|
export interface RoleUpdateEvent {
|
||||||
|
profile: Profile,
|
||||||
|
newRole: ProfileRole
|
||||||
|
}
|
||||||
135
src/server/profiles/manager.ts
Normal file
135
src/server/profiles/manager.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { ServerContentBase } from "../ContentBase.ts";
|
||||||
|
import NameDictionary from "./dict.ts";
|
||||||
|
import Profile from "./profile.ts";
|
||||||
|
import { SelfAccount, type RecNetAccount } from "./types/profile.ts";
|
||||||
|
import Command from "./../commands/command.ts";
|
||||||
|
import z from "zod";
|
||||||
|
import { ProfileRole } from "../platforms/base.ts";
|
||||||
|
|
||||||
|
const profiles: Map<number, Profile> = new Map();
|
||||||
|
|
||||||
|
class ProfileManagerBase extends ServerContentBase {
|
||||||
|
|
||||||
|
static profilesKey = "profiles";
|
||||||
|
static profileByNameKey = "profileName";
|
||||||
|
|
||||||
|
/*async exists(id: number) {
|
||||||
|
return (await this.kv.getKv().get([ ProfileManagerBase.profilesKey, id ])).value !== null
|
||||||
|
}*/
|
||||||
|
|
||||||
|
async #getUnusedId() {
|
||||||
|
let id = Math.round(Math.random() * 2_147_483_647);
|
||||||
|
if (await this.get(id)) id = await this.#getUnusedId();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getUnusedUsername() {
|
||||||
|
const adjective = NameDictionary.Adjectives[Math.floor(Math.random() * NameDictionary.Adjectives.length)];
|
||||||
|
const noun = NameDictionary.Nouns[Math.floor(Math.random() * NameDictionary.Nouns.length)];
|
||||||
|
const discriminator = Math.round(Math.random() * 1000);
|
||||||
|
let username = `${adjective}${noun}#${discriminator}`;
|
||||||
|
if (await this.getByUsername(username)) username = await this.#getUnusedUsername();
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(username?: string) {
|
||||||
|
const id = await this.#getUnusedId();
|
||||||
|
const newUsername = username? username : await this.#getUnusedUsername();
|
||||||
|
const newProfile: RecNetAccount = {
|
||||||
|
accountId: id,
|
||||||
|
username: newUsername,
|
||||||
|
displayName: newUsername,
|
||||||
|
profileImage: "DefaultProfileImage.png",
|
||||||
|
createdAt: new Date()
|
||||||
|
}
|
||||||
|
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, id ], newProfile);
|
||||||
|
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newUsername ], id);
|
||||||
|
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: number) {
|
||||||
|
const profile = profiles.get(id);
|
||||||
|
if (!profile) {
|
||||||
|
const info = await this.kv.getKv().get<SelfAccount>([ ProfileManagerBase.profilesKey, id ]);
|
||||||
|
if (!info.value) return null;
|
||||||
|
const prof = new Profile(info.value, this.kv, this.server);
|
||||||
|
profiles.set(id, prof);
|
||||||
|
return prof;
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMany(...ids: number[]) {
|
||||||
|
return (await Promise.all(ids.map(id => this.get(id)))).filter(prof => prof !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll() {
|
||||||
|
const keys = this.kv.getKv().list({ prefix: [ ProfileManagerBase.profilesKey ] });
|
||||||
|
const awaitedKeys = await Array.fromAsync(keys);
|
||||||
|
return awaitedKeys.map(entry => entry.key).map(val => val[1]).filter(val => typeof val == 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUsername(username: string) {
|
||||||
|
const id = (await this.kv.getKv().get<number>([ ProfileManagerBase.profilesKey, username ])).value;
|
||||||
|
if (typeof id == 'number') return this.get(id);
|
||||||
|
else return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
override start() {
|
||||||
|
this.server.Commands.addRootCommand(new Command({
|
||||||
|
key: ['account', 'profile', 'acc', 'prof'],
|
||||||
|
subcommands: [
|
||||||
|
new Command({
|
||||||
|
key: ['get', 'g', 'fetch', 'f'],
|
||||||
|
exec: async (id: number) => {
|
||||||
|
const prof = await this.get(id);
|
||||||
|
if (!prof) return prof;
|
||||||
|
else return await prof.export();
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(Number)
|
||||||
|
]),
|
||||||
|
help: 'Fetch a profile: <id: number>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['getall', 'listall', 'fetchall', 'all', 'a'],
|
||||||
|
exec: async () => {
|
||||||
|
const ids = await this.getAll();
|
||||||
|
return ids;
|
||||||
|
},
|
||||||
|
zod: z.tuple([]),
|
||||||
|
help: 'Fetch all profile IDs'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['getrole', 'gr'],
|
||||||
|
exec: async (id: number) => {
|
||||||
|
const profile = await this.get(id);
|
||||||
|
if (!profile) return new Error("No such profile");
|
||||||
|
else return await profile.getRole();
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(Number)
|
||||||
|
]),
|
||||||
|
help: 'Set the profile role: <id: number>'
|
||||||
|
}),
|
||||||
|
new Command({
|
||||||
|
key: ['setrole', 'sr'],
|
||||||
|
exec: async (id: number, role: ProfileRole) => {
|
||||||
|
const profile = await this.get(id);
|
||||||
|
if (!profile) return new Error("No such profile");
|
||||||
|
else return await profile.setRole(role);
|
||||||
|
},
|
||||||
|
zod: z.tuple([
|
||||||
|
z.string().transform(Number),
|
||||||
|
z.string()
|
||||||
|
]),
|
||||||
|
help: 'Set the profile role: <id: number, role: "developer" | "moderator" | "screenshare" | "user">'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileManagerBase;
|
||||||
99
src/server/profiles/profile.ts
Normal file
99
src/server/profiles/profile.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import KV from "../persistence/kv.ts";
|
||||||
|
import { ProfileRole } from "../platforms/base.ts";
|
||||||
|
import { type ServerBase } from "../server.ts";
|
||||||
|
import { type SignalRSocketHandler } from "../socket/signalr/socket.ts";
|
||||||
|
import ProfileManagerBase from "./manager.ts";
|
||||||
|
import { recNetAccountSchema, SelfAccount, type RecNetAccount } from "./types/profile.ts";
|
||||||
|
|
||||||
|
class Profile {
|
||||||
|
|
||||||
|
#id: number;
|
||||||
|
#kv: KV;
|
||||||
|
|
||||||
|
#socket: SignalRSocketHandler | null = null;
|
||||||
|
#server: ServerBase;
|
||||||
|
|
||||||
|
#selfAcc: SelfAccount;
|
||||||
|
|
||||||
|
constructor(acc: SelfAccount, kv: KV, server: ServerBase) {
|
||||||
|
this.#id = acc.accountId;
|
||||||
|
this.#selfAcc = acc;
|
||||||
|
this.#kv = kv;
|
||||||
|
this.#server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #saveSelfAcc() {
|
||||||
|
await this.#kv.getKv().set(this.#constructProfilePropertyKey(), this.#selfAcc);
|
||||||
|
this.#server.emit('profile.update', { profile: this });
|
||||||
|
}
|
||||||
|
|
||||||
|
#constructProfilePropertyKey(...keys: (string | undefined)[]) {
|
||||||
|
return [ ProfileManagerBase.profilesKey, this.#id, ...keys.filter(val => typeof val == 'string') ];
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername() {
|
||||||
|
return this.#selfAcc.username;
|
||||||
|
}
|
||||||
|
async setUsername(username: string) {
|
||||||
|
this.#kv.getKv().delete([ ProfileManagerBase.profilesKey, this.#selfAcc.username ]);
|
||||||
|
this.#kv.getKv().set([ ProfileManagerBase.profilesKey, username ], this.getId());
|
||||||
|
|
||||||
|
this.#selfAcc.username = username;
|
||||||
|
await this.#saveSelfAcc();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplayName() {
|
||||||
|
return this.#selfAcc.displayName;
|
||||||
|
}
|
||||||
|
async setDisplayName(displayName: string) {
|
||||||
|
this.#selfAcc.displayName = displayName;
|
||||||
|
await this.#saveSelfAcc();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBio(){
|
||||||
|
const key = this.#constructProfilePropertyKey('bio');
|
||||||
|
const val = await this.#kv.getKv().get<string>(key);
|
||||||
|
if (!val.value) return null;
|
||||||
|
else return val.value;
|
||||||
|
}
|
||||||
|
async setBio(bio: string) {
|
||||||
|
const key = this.#constructProfilePropertyKey('bio');
|
||||||
|
await this.#kv.getKv().set(key, bio);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRole(): Promise<ProfileRole> {
|
||||||
|
const val = (await this.#kv.getKv().get<ProfileRole>(this.#constructProfilePropertyKey('role'))).value;
|
||||||
|
if (!val) return ProfileRole.User;
|
||||||
|
else return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRole(role: ProfileRole) {
|
||||||
|
await this.#kv.getKv().set(this.#constructProfilePropertyKey('role'), role);
|
||||||
|
this.#server.emit('profile.roleupdate', { profile: this, newRole: role });
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
return this.#id;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocketHandler() {
|
||||||
|
return this.#socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSocketHandler(handler: SignalRSocketHandler | null) {
|
||||||
|
this.#socket = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export(): RecNetAccount | null {
|
||||||
|
const val = recNetAccountSchema.safeParse(this.#selfAcc);
|
||||||
|
if (val.success) return val.data;
|
||||||
|
else return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
selfExport(): SelfAccount {
|
||||||
|
return this.#selfAcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
37
src/server/profiles/types/profile.ts
Normal file
37
src/server/profiles/types/profile.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { ProfileRole } from "../../platforms/base.ts";
|
||||||
|
|
||||||
|
export const recNetAccountSchema = z.object({
|
||||||
|
accountId: z.number(),
|
||||||
|
profileImage: z.string(),
|
||||||
|
isJunior: z.optional(z.boolean()),
|
||||||
|
username: z.string(),
|
||||||
|
displayName: z.string(),
|
||||||
|
createdAt: z.union([ z.date(), z.string().transform((arg, ctx) => {
|
||||||
|
const d = new Date(arg);
|
||||||
|
if (isNaN(d.getTime())) {
|
||||||
|
ctx.addIssue("createdAt must be an ISO date");
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
else return d;
|
||||||
|
}) ])
|
||||||
|
});
|
||||||
|
export const selfAccountSchema = recNetAccountSchema.extend({
|
||||||
|
email: z.optional(z.string()),
|
||||||
|
phone: z.optional(z.string()),
|
||||||
|
juniorState: z.optional(z.int()),
|
||||||
|
availableUsernameChanges: z.optional(z.int())
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RecNetAccount = z.infer<typeof recNetAccountSchema>;
|
||||||
|
export type SelfAccount = z.infer<typeof selfAccountSchema>;
|
||||||
|
|
||||||
|
export const profileTokenSchema = z.object({
|
||||||
|
iss: z.string(),
|
||||||
|
exp: z.number().min(Date.now()),
|
||||||
|
iat: z.number().min(Date.now()),
|
||||||
|
sub: z.number(),
|
||||||
|
role: z.enum(ProfileRole)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProfileToken = z.infer<typeof profileTokenSchema>;
|
||||||
23
src/server/server.ts
Normal file
23
src/server/server.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { EventManager } from "./baseevent.ts";
|
||||||
|
import { CommandsBase } from "./commands/commands.ts";
|
||||||
|
import GameConfigsBase from "./gameconfigs/base.ts";
|
||||||
|
import { PlatformsManager } from "./platforms/base.ts";
|
||||||
|
import { type ProfileUpdateEvent } from "./profiles/events/ProfileUpdate.ts";
|
||||||
|
import { type RoleUpdateEvent } from "./profiles/events/RoleUpdate.ts";
|
||||||
|
import ProfileManagerBase from "./profiles/manager.ts";
|
||||||
|
|
||||||
|
interface ServerEvents {
|
||||||
|
'profile.roleupdate': RoleUpdateEvent,
|
||||||
|
'profile.update': ProfileUpdateEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerBase extends EventManager<ServerEvents> {
|
||||||
|
Profiles = new ProfileManagerBase(this, 'profiles', true);
|
||||||
|
GameConfigs = new GameConfigsBase(this, 'gameconfigs', true);
|
||||||
|
Commands = new CommandsBase(this, 'commands');
|
||||||
|
Platforms = new PlatformsManager(this, 'platforms', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Server = new ServerBase();
|
||||||
|
export { ServerBase };
|
||||||
|
export default Server;
|
||||||
66
src/server/socket/console/socket.ts
Normal file
66
src/server/socket/console/socket.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Logging, { LoggingListeners } from "@proxnet/undead-logging";
|
||||||
|
import { ConsoleEvent } from "./types.ts";
|
||||||
|
import { type ConsoleItem, ConsoleItemSchema } from "./zod.ts";
|
||||||
|
import Server from "../../server.ts";
|
||||||
|
import { getSourceAddress } from "../../../util/net.ts";
|
||||||
|
import { consoleSockets } from "../../../main.ts";
|
||||||
|
import chalk from "npm:chalk@^5.3.0";
|
||||||
|
|
||||||
|
export default class SocketConsoleHandler {
|
||||||
|
|
||||||
|
#socket: WebSocket;
|
||||||
|
#log = new Logging('Console');
|
||||||
|
|
||||||
|
#logCb = (msg: string) => {
|
||||||
|
this.send(ConsoleEvent.Message, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(socket: WebSocket, req: Request, serveInfo: Deno.ServeHandlerInfo<Deno.NetAddr>) {
|
||||||
|
this.#socket = socket;
|
||||||
|
|
||||||
|
this.#socket.onmessage = ev => {
|
||||||
|
this.#onMsg(ev);
|
||||||
|
};
|
||||||
|
this.#socket.onerror = () => {
|
||||||
|
this.#log.e(`Socket from ${getSourceAddress(req, serveInfo.remoteAddr)} closed due to error`);
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
this.#socket.onclose = () => {
|
||||||
|
this.#log.n(`${getSourceAddress(req, serveInfo.remoteAddr)} closed a socket`);
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
this.#socket.onopen = () => {
|
||||||
|
this.#log.n(`${getSourceAddress(req, serveInfo.remoteAddr)} opened a socket`);
|
||||||
|
}
|
||||||
|
LoggingListeners.onmsg('basic', this.#logCb);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
LoggingListeners.offmsg('basic', this.#logCb);
|
||||||
|
consoleSockets.delete(this);
|
||||||
|
this.#socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
send(ev: ConsoleEvent, d: string) {
|
||||||
|
if (ev == ConsoleEvent.Command) this.#socket.send(JSON.stringify({ e: ev, d }));
|
||||||
|
else if (ev == ConsoleEvent.Message) this.#socket.send(JSON.stringify({ e: ev, m: d }));
|
||||||
|
else if (ev == ConsoleEvent.Close) this.#socket.send(JSON.stringify({ e: ev }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async #onMsg(ev: MessageEvent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(ev.data) as ConsoleItem;
|
||||||
|
const zodd = ConsoleItemSchema.safeParse(parsed);
|
||||||
|
if (!zodd.success) this.destroy();
|
||||||
|
else if (zodd.data.e == ConsoleEvent.Command) {
|
||||||
|
const data = await Server.Commands.dispatch(...zodd.data.d.split(' '));
|
||||||
|
if (data instanceof Error) throw data;
|
||||||
|
this.send(ConsoleEvent.Message, chalk.gray(`> ${chalk.yellow(data)}`));
|
||||||
|
}
|
||||||
|
else if (zodd.data.e == ConsoleEvent.Close) this.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.e(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
5
src/server/socket/console/types.ts
Normal file
5
src/server/socket/console/types.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum ConsoleEvent {
|
||||||
|
Message,
|
||||||
|
Command,
|
||||||
|
Close
|
||||||
|
}
|
||||||
25
src/server/socket/console/zod.ts
Normal file
25
src/server/socket/console/zod.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import z from "zod";
|
||||||
|
import { ConsoleEvent } from "./types.ts";
|
||||||
|
|
||||||
|
export const ConsoleCommandSchema = z.object({
|
||||||
|
e: z.literal(ConsoleEvent.Command),
|
||||||
|
d: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConsoleMessageSchema = z.object({
|
||||||
|
e: z.literal(ConsoleEvent.Message),
|
||||||
|
m: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConsoleCloseSchema = z.object({
|
||||||
|
e: z.literal(ConsoleEvent.Close),
|
||||||
|
r: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConsoleItemSchema = z.discriminatedUnion('e', [
|
||||||
|
ConsoleCommandSchema,
|
||||||
|
ConsoleMessageSchema,
|
||||||
|
ConsoleCloseSchema
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type ConsoleItem = z.infer<typeof ConsoleItemSchema>;
|
||||||
176
src/server/socket/signalr/socket.ts
Normal file
176
src/server/socket/signalr/socket.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import Logging from "@proxnet/undead-logging";
|
||||||
|
import {
|
||||||
|
CompletionMessage,
|
||||||
|
Message,
|
||||||
|
MessageKind,
|
||||||
|
PushNotificationId,
|
||||||
|
SignalMessageType,
|
||||||
|
SignalRMessage,
|
||||||
|
SignalRMessageSchema,
|
||||||
|
TargetResult,
|
||||||
|
TargetResultFailure,
|
||||||
|
TargetResultNotATarget,
|
||||||
|
TargetResultSuccess,
|
||||||
|
TargetResultType
|
||||||
|
} from "./types.ts";
|
||||||
|
import { SocketTarget } from "./targets/targetbase.ts";
|
||||||
|
import type Profile from "../../profiles/profile.ts";
|
||||||
|
import { detailedLog } from "../../../main.ts";
|
||||||
|
|
||||||
|
const logmessages = true;
|
||||||
|
|
||||||
|
export class SignalRSocketHandler {
|
||||||
|
|
||||||
|
#log: Logging = new Logging("SignalMock-");
|
||||||
|
|
||||||
|
#socket: WebSocket;
|
||||||
|
#profile: Profile;
|
||||||
|
|
||||||
|
#Targets: Map<string, SocketTarget> = new Map();
|
||||||
|
|
||||||
|
#killed = false;
|
||||||
|
|
||||||
|
constructor(socket: WebSocket, player: Profile) {
|
||||||
|
|
||||||
|
this.#socket = socket;
|
||||||
|
this.#profile = player;
|
||||||
|
|
||||||
|
this.#init();
|
||||||
|
|
||||||
|
player.setSocketHandler(this);
|
||||||
|
|
||||||
|
//this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget(this));
|
||||||
|
|
||||||
|
for (const target of this.#Targets.values()) target.onInit();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async #dispatchTarget<T = unknown>(target: string, args: unknown): Promise<TargetResult> {
|
||||||
|
if (this.#killed) {
|
||||||
|
const error = "Tried to dispatch socket target on dead socket";
|
||||||
|
this.#log.w(error);
|
||||||
|
return { type: TargetResultType.Failure, err: error };
|
||||||
|
}
|
||||||
|
const targetExec = this.#Targets.get(target);
|
||||||
|
if (!targetExec) return { type: TargetResultType.NotATarget } as TargetResultNotATarget;
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess<T>;
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.w(`Target '${target}' function error: ${err}`);
|
||||||
|
if (err instanceof Error) return { type: TargetResultType.Failure, err: err } as TargetResultFailure;
|
||||||
|
else return { type: TargetResultType.Failure, err: `${err}` } as TargetResultFailure;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #onMessage(message: Message) {
|
||||||
|
if (message.kind == MessageKind.Protocol) {
|
||||||
|
this.sendRaw({});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
if (logmessages) this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`);
|
||||||
|
if (message.data.type == SignalMessageType.Invocation && message.data.invocationId) { // don't send completion messages for nonblocking invocations
|
||||||
|
const res = await this.#dispatchTarget(message.data.target, message.data.arguments[0]); // rec room only uses the first index
|
||||||
|
if (res.type == TargetResultType.Success) {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
result: JSON.stringify(res.data)
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
} else if (res.type == TargetResultType.Failure) {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
error: res.err instanceof Error ? res.err.message : res.err
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
} else {
|
||||||
|
const signalRes: CompletionMessage = {
|
||||||
|
type: SignalMessageType.Completion,
|
||||||
|
invocationId: message.data.invocationId,
|
||||||
|
error: "Target not found"
|
||||||
|
}
|
||||||
|
this.sendRaw(signalRes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#init() {
|
||||||
|
this.#log.source += this.#profile.getId().toString();
|
||||||
|
|
||||||
|
this.#log.i(`Created hub socket`);
|
||||||
|
|
||||||
|
this.#socket.addEventListener('message', message => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
const str = dec.decode(message.data);
|
||||||
|
const data = JSON.parse(str.substring(0, str.length - 1));
|
||||||
|
|
||||||
|
const parseResult = SignalRMessageSchema.safeParse(data);
|
||||||
|
if (parseResult.success) this.#onMessage({
|
||||||
|
kind: MessageKind.Data,
|
||||||
|
data: parseResult.data as SignalRMessage
|
||||||
|
});
|
||||||
|
else {
|
||||||
|
this.#onMessage({
|
||||||
|
kind: MessageKind.Protocol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.#log.e(`Socket error: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.#socket.addEventListener('close', this.destroy(this, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(handler: SignalRSocketHandler, internal: boolean | undefined = false) {
|
||||||
|
return (ev: CloseEvent) => {
|
||||||
|
handler.#killed = true;
|
||||||
|
|
||||||
|
let errorReason = "Socket closed by server";
|
||||||
|
this.#log.d(`Socket close code: ${ev.code}`);
|
||||||
|
if (ev.reason.includes('Bye!')) errorReason = "Socket closed by client request";
|
||||||
|
|
||||||
|
handler.sendRaw({ type: 7, error: errorReason });
|
||||||
|
|
||||||
|
if (!internal) handler.#socket.close();
|
||||||
|
handler.#log.i(`Closed socket`);
|
||||||
|
handler.#profile.setSocketHandler(null);
|
||||||
|
|
||||||
|
for (const target of handler.#Targets.values()) target.onDestroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendRaw(data: object) {
|
||||||
|
this.#socket.send(`${JSON.stringify(data)}\u001e`);
|
||||||
|
if (logmessages) {
|
||||||
|
const isHandshake = JSON.stringify(data) == '{}';
|
||||||
|
if (isHandshake) this.#log.d(detailedLog([`SERVER MESSAGE`,
|
||||||
|
`Type: Handshake`
|
||||||
|
]));
|
||||||
|
else this.#log.d(detailedLog([`SERVER MESSAGE`,
|
||||||
|
`Type: ${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`,
|
||||||
|
`Content: ${JSON.stringify(data)}`
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendNotification(id: PushNotificationId | string, args?: object) {
|
||||||
|
const msg: SignalRMessage = {
|
||||||
|
type: SignalMessageType.Invocation,
|
||||||
|
target: "Notification",
|
||||||
|
arguments: [JSON.stringify({
|
||||||
|
Id: id,
|
||||||
|
Msg: args ? args : {}
|
||||||
|
})]
|
||||||
|
}
|
||||||
|
this.sendRaw(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
41
src/server/socket/signalr/targets/targetbase.ts
Normal file
41
src/server/socket/signalr/targets/targetbase.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/* 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 { SignalRSocketHandler } from "../socket.ts";
|
||||||
|
|
||||||
|
export class SocketTarget {
|
||||||
|
|
||||||
|
socket: SignalRSocketHandler;
|
||||||
|
|
||||||
|
constructor(socket: SignalRSocketHandler) {
|
||||||
|
this.socket = socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
onInit() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore require-await
|
||||||
|
async exec(_args: unknown) {
|
||||||
|
throw new Error("Execution for this target is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
190
src/server/socket/signalr/types.ts
Normal file
190
src/server/socket/signalr/types.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/* 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 { z } from "zod";
|
||||||
|
|
||||||
|
export enum MessageKind {
|
||||||
|
Protocol,
|
||||||
|
Data
|
||||||
|
}
|
||||||
|
export interface MessageBase {
|
||||||
|
kind: MessageKind
|
||||||
|
}
|
||||||
|
export interface DataMessage extends MessageBase {
|
||||||
|
kind: MessageKind.Data,
|
||||||
|
data: SignalRMessage
|
||||||
|
}
|
||||||
|
export interface ProtocolMessage extends MessageBase {
|
||||||
|
kind: MessageKind.Protocol
|
||||||
|
}
|
||||||
|
export type Message = ProtocolMessage | DataMessage;
|
||||||
|
|
||||||
|
export type SignalRMessage =
|
||||||
|
| InvocationMessage
|
||||||
|
| StreamItemMessage
|
||||||
|
| CompletionMessage
|
||||||
|
| PingMessage
|
||||||
|
| CloseMessage;
|
||||||
|
|
||||||
|
export enum SignalMessageType {
|
||||||
|
Handshake,
|
||||||
|
Invocation,
|
||||||
|
StreamItem,
|
||||||
|
Completion,
|
||||||
|
StreamInvocation,
|
||||||
|
CancelInvocation,
|
||||||
|
Ping,
|
||||||
|
Close
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseMessage {
|
||||||
|
type: SignalMessageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvocationMessage extends BaseMessage {
|
||||||
|
type: SignalMessageType.Invocation;
|
||||||
|
target: string;
|
||||||
|
arguments: unknown[];
|
||||||
|
invocationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamItemMessage extends BaseMessage {
|
||||||
|
type: SignalMessageType.StreamItem;
|
||||||
|
invocationId: string;
|
||||||
|
item: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionMessage extends BaseMessage {
|
||||||
|
type: SignalMessageType.Completion;
|
||||||
|
invocationId: string;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingMessage extends BaseMessage {
|
||||||
|
type: SignalMessageType.Ping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloseMessage extends BaseMessage {
|
||||||
|
type: SignalMessageType.Close;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BaseMessageSchema = z.object({
|
||||||
|
type: z.enum(SignalMessageType),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const InvocationMessageSchema = BaseMessageSchema.extend({
|
||||||
|
type: z.literal(SignalMessageType.Invocation),
|
||||||
|
target: z.string(),
|
||||||
|
arguments: z.array(z.unknown()),
|
||||||
|
invocationId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StreamItemMessageSchema = BaseMessageSchema.extend({
|
||||||
|
type: z.literal(SignalMessageType.StreamItem),
|
||||||
|
invocationId: z.string(),
|
||||||
|
item: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CompletionMessageSchema = BaseMessageSchema.extend({
|
||||||
|
type: z.literal(SignalMessageType.Completion),
|
||||||
|
invocationId: z.string(),
|
||||||
|
result: z.unknown().optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PingMessageSchema = BaseMessageSchema.extend({
|
||||||
|
type: z.literal(SignalMessageType.Ping),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CloseMessageSchema = BaseMessageSchema.extend({
|
||||||
|
type: z.literal(SignalMessageType.Close),
|
||||||
|
error: z.optional(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SignalRMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
InvocationMessageSchema,
|
||||||
|
StreamItemMessageSchema,
|
||||||
|
CompletionMessageSchema,
|
||||||
|
PingMessageSchema,
|
||||||
|
CloseMessageSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export enum TargetResultType {
|
||||||
|
Success,
|
||||||
|
Failure,
|
||||||
|
NotATarget
|
||||||
|
}
|
||||||
|
export interface TargetResultBase {
|
||||||
|
type: TargetResultType
|
||||||
|
}
|
||||||
|
export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
|
||||||
|
type: TargetResultType.Success,
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
export interface TargetResultFailure extends TargetResultBase {
|
||||||
|
type: TargetResultType.Failure
|
||||||
|
err: string | Error
|
||||||
|
}
|
||||||
|
export interface TargetResultNotATarget extends TargetResultBase {
|
||||||
|
type: TargetResultType.NotATarget
|
||||||
|
}
|
||||||
|
export type TargetResult = TargetResultSuccess | TargetResultFailure | TargetResultNotATarget;
|
||||||
|
|
||||||
|
export enum PushNotificationId {
|
||||||
|
RelationshipChanged = 1,
|
||||||
|
MessageReceived,
|
||||||
|
MessageDeleted,
|
||||||
|
PresenceHeartbeatResponse,
|
||||||
|
RefreshLogin,
|
||||||
|
Logout,
|
||||||
|
SubscriptionUpdateProfile = 11,
|
||||||
|
SubscriptionUpdatePresence,
|
||||||
|
SubscriptionUpdateGameSession,
|
||||||
|
SubscriptionUpdateRoom = 15,
|
||||||
|
SubscriptionUpdateRoomPlaylist,
|
||||||
|
ModerationQuitGame = 20,
|
||||||
|
ModerationUpdateRequired,
|
||||||
|
ModerationKick,
|
||||||
|
ModerationKickAttemptFailed,
|
||||||
|
ModerationRoomBan,
|
||||||
|
ServerMaintenance,
|
||||||
|
GiftPackageReceived = 30,
|
||||||
|
GiftPackageReceivedImmediate,
|
||||||
|
GiftPackageRewardSelectionReceived,
|
||||||
|
ProfileJuniorStatusUpdate = 40,
|
||||||
|
RelationshipsInvalid = 50,
|
||||||
|
StorefrontBalanceAdd = 60,
|
||||||
|
StorefrontBalanceUpdate,
|
||||||
|
StorefrontBalancePurchase,
|
||||||
|
ConsumableMappingAdded = 70,
|
||||||
|
ConsumableMappingRemoved,
|
||||||
|
PlayerEventCreated = 80,
|
||||||
|
PlayerEventUpdated,
|
||||||
|
PlayerEventDeleted,
|
||||||
|
PlayerEventResponseChanged,
|
||||||
|
PlayerEventResponseDeleted,
|
||||||
|
PlayerEventStateChanged,
|
||||||
|
ChatMessageReceived = 90,
|
||||||
|
CommunityBoardUpdate = 95,
|
||||||
|
CommunityBoardAnnouncementUpdate,
|
||||||
|
InventionModerationStateChanged = 100,
|
||||||
|
FreeGiftButtonItemsAdded = 110,
|
||||||
|
LocalRoomKeyCreated = 120,
|
||||||
|
LocalRoomKeyDeleted
|
||||||
|
}
|
||||||
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