image library changes, bit flags changes

This commit is contained in:
2025-09-16 18:01:55 -04:00
parent 5934f1a91c
commit c4f32b1940
16 changed files with 119 additions and 284 deletions

View File

@@ -11,11 +11,12 @@ import { SignalRSocketHandler } from "./server/socket/signalr/socket.ts";
import { PushNotificationId } from "./server/socket/signalr/types.ts";
import { genericResponse } from "./util/api.ts";
import { getNetConfig } from "./net.ts";
import { PlatformType, TokenFormat, TokenType } from "./server/platforms/types.ts";
import { PlatformMask, PlatformType, TokenFormat, TokenType } from "./server/platforms/types.ts";
import { HonoEnv } from "./util/types.ts";
import { Context } from "@hono/hono";
import { compress } from "@hono/hono/compress";
import { env } from "./env.ts";
import { BitFlags } from "./util/flags.ts";
LoggingConfiguration.resetTimeFormat = TimeFormat.Unix;
LoggingConfiguration.resetLogTiming = LogTiming.Microtask;
@@ -115,7 +116,7 @@ const server = Deno.serve({
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 { response, socket } = Deno.upgradeWebSocket(req, { idleTimeout: 60 });
const handler = new SignalRSocketHandler(socket, profile);
gameSockets.add(handler);
socket.onclose = () => {
@@ -190,6 +191,6 @@ Server.Commands.addRootCommand(new Command({
Server.on('server.start', () => {
Server.Profiles.get(1).then(prof => {
if (!prof) Server.Profiles.create(PlatformType.HeadlessBot, "", "Coach", 1);
if (!prof) Server.Profiles.create(PlatformType.HeadlessBot, new BitFlags<PlatformMask>(PlatformMask.All), "", "Coach", 1);
});
});

View File

@@ -3,10 +3,11 @@ import { RateLimiter, recNetResultResponse, statusResponse } from "../../../util
import Server from "../../../server/server.ts";
import z from "zod";
import { transformCheckEnum, typedZValidator } from "../../../util/validators.ts";
import { PlatformType } from "../../../server/platforms/types.ts";
import { PlatformMask, PlatformType } from "../../../server/platforms/types.ts";
import Steam from "../../../util/steam/steam.ts";
import { HTTPStatus } from "@oneday/http-status";
import { accountRoute } from "./me/root.ts";
import { BitFlags } from "../../../util/flags.ts";
export const route = createHonoRoute('/account');
@@ -50,7 +51,7 @@ route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form'
const cachedlogins = await Server.Platforms.getCachedLogins(form.platform, form.platformId, true);
if (cachedlogins.length == 0) {
const profile = await Server.Profiles.create(form.platform, form.platformId, steam[0].realname ?? steam[0].personaname);
const profile = await Server.Profiles.create(form.platform, new BitFlags<PlatformMask>(PlatformMask.Steam), form.platformId, steam[0].realname ?? steam[0].personaname);
if (!profile) return recNetResultResponse(c, HTTPStatus.OK, false, "Account could not be created");
Server.Content.steamAvatarDownloadForProfile(profile, steam[0].avatarfull);
@@ -62,7 +63,7 @@ route.app.post('/create', postCreateRateLimiter.middle(), typedZValidator('form'
} else {
const profile = await Server.Profiles.create(form.platform, form.platformId);
const profile = await Server.Profiles.create(form.platform, new BitFlags<PlatformMask>(PlatformMask.Steam), form.platformId);
if (!profile) return recNetResultResponse(c, HTTPStatus.OK, false, "Account could not be created");
return c.json({

View File

@@ -0,0 +1,8 @@
import { authenticate } from "../../../util/api.ts";
import { createHonoRoute } from "../../../util/import.ts";
export const route = createHonoRoute("/parentalcontrol");
route.app.get('/me', authenticate, c => {
return c.json({ accountId: c.get('profile').getId(), disallowInAppPurchases: false });
});

View File

@@ -20,7 +20,7 @@ route.app.get('/v1/:id', authenticate, typedZValidator('param', getRepIdParamSch
const getRepBulkBodySchema = z.object({
Ids: z.array(z.coerce.number())
});
route.app.post('/v1/bulk', authenticate, typedZValidator('form', getRepBulkBodySchema), async c => {
route.app.post('/v1/bulk', typedZValidator('form', getRepBulkBodySchema), async c => {
const ids = c.req.valid('form').Ids;
if (typeof ids == 'object') {

View File

@@ -3,6 +3,7 @@ import { createHonoRoute } from "../../../util/import.ts";
import { typedZValidator } from "../../../util/validators.ts";
import Server from "../../../server/server.ts";
import { authenticate } from "../../../util/api.ts";
import { RoomAccessibility } from "../../../server/rooms/internal/RoomDataTypes.ts";
export const route = createHonoRoute("/rooms");
@@ -35,8 +36,9 @@ route.app.get('/v2/myrooms', authenticate, async c => {
return c.json(rooms);
});
route.app.get('/v1/hot', authenticate, async c => {
route.app.get('/v1/hot', async c => {
// temporary! parse tags and manage "hot" rooms
const agRoomIds = Server.Rooms.getAgRoomIds();
const factories = await Server.Rooms.getMany(...agRoomIds.values().toArray());
return c.json((await Promise.all(factories.map(factory => factory.export()))).map(roomDetails => roomDetails.Room));
return c.json((await Promise.all(factories.map(factory => factory.export()))).map(roomDetails => roomDetails.Room).filter(room => room.Accessibility == RoomAccessibility.Public));
});

View File

@@ -100,7 +100,8 @@ route.app.post('/token', typedZValidator('form', tokenGrantSchema), async c => {
if (!profile) return error(TokenRequestError.AccessDenied);
return c.json({
token: await Server.Platforms.getToken(profile, TokenType.Access),
access_token: await Server.Platforms.getToken(profile, TokenType.Access),
refresh_token: await Server.Platforms.getToken(profile, TokenType.Refresh),
});
} catch (err) {
log.w(`Authentication error (token req): ${(err as Error).stack}`);
@@ -112,12 +113,10 @@ route.app.post('/token', typedZValidator('form', tokenGrantSchema), async c => {
const profile = await Server.Profiles.get(form.account_id);
if (!profile) return error(TokenRequestError.InvalidRequest, "No such profile");
await Server.Platforms.updateLastLoginTime(form.platform, form.platform_id, form.account_id);
const accessToken = await Server.Platforms.getToken(profile, TokenType.Access);
const refreshToken = await Server.Platforms.getToken(profile, TokenType.Refresh);
return c.json({
access_token: accessToken,
refresh_token: refreshToken,
access_token: await Server.Platforms.getToken(profile, TokenType.Access),
refresh_token: await Server.Platforms.getToken(profile, TokenType.Refresh),
});
} else return error(TokenRequestError.InvalidRequest, "No such profile");
});

View File

@@ -8,28 +8,51 @@ import Logging from "@proxnet/undead-logging";
import { statusResponse } from "../../util/api.ts";
import { HTTPStatus } from "@oneday/http-status";
import { Buffer } from "node:buffer";
import sharp from "sharp";
import { Image } from "imagescript";
export const route = createHonoRoute('/img');
interface StaticImageRedirect {
org: string,
new: string
}
const redirects: StaticImageRedirect[] = [
{
org: "PaintballVR.png",
new: "Paintball.png"
}
];
const log = new Logging("ImageRoute");
async function convertImage(query: ImageQuery, data: Uint8Array<ArrayBufferLike>): Promise<Uint8Array<ArrayBufferLike> | null> {
try {
const image = sharp(data);
const rootMetadata = await image.metadata();
const squareSize = Math.min(rootMetadata.width, rootMetadata.height);
if (query.cropSquare) image.resize(squareSize, squareSize);
const image = await Image.decode(data);
const newImage = sharp(await image.png().toBuffer());
if (query.width && query.height)
newImage.resize(query.width, query.height);
if (typeof query.height == 'number' && query.height >= image.height) query.height = image.height;
if (typeof query.width == 'number' && query.width >= image.width) query.width = image.width;
if (query.cropSquare) {
if (image.height > image.width) image.cover(image.height, image.height);
else image.cover(image.width, image.width);
}
if (query.width && query.height) {
if (image.height > image.width) {
image.resize(query.width, -1);
image.cover(query.width, query.height);
} else {
image.resize(-1, query.height);
image.cover(query.width, query.height);
}
}
else if (query.width)
newImage.resize(query.width);
image.resize(query.width, -1);
else if (query.height)
newImage.resize(undefined, query.height);
image.resize(-1, query.height);
return await newImage.png().toBuffer();
return await image.encode(6);
} catch (err) {
log.w(`Image transformation failed: ${(err as Error).stack}`);
return null;
@@ -43,6 +66,7 @@ const imgQuerySchema = z.object({
cropSquare: z.coerce.boolean().optional(),
width: z.coerce.number().min(64).max(3840).optional(),
height: z.coerce.number().min(64).max(2160).optional(),
sig: z.literal('p1').optional()
});
type ImageQuery = z.infer<typeof imgQuerySchema>;
@@ -51,19 +75,22 @@ route.app.get('/:imgName',
typedZValidator('query', imgQuerySchema),
async c => {
const { imgName } = c.req.valid('param');
const params = c.req.valid('param');
const query = c.req.valid('query');
const redirect = redirects.find(red => red.org === params.imgName);
if (redirect) params.imgName = redirect.new;
const datas: Uint8Array<ArrayBufferLike>[] = (await Promise.all<Uint8Array<ArrayBufferLike> | null>([
new Promise(resolve => {
Deno.readFile(path.join(RootPath, "res/baseimg/", imgName)).then(img => {
Deno.readFile(path.join(RootPath, "res/baseimg/", params.imgName)).then(img => {
resolve(img);
}).catch(() => {
resolve(null);
});
}),
new Promise(resolve => {
Server.Content.getFile(`img/${imgName}`).then(file => {
Server.Content.getFile(`img/${params.imgName}`).then(file => {
if (file) resolve(file.Data);
else resolve(null);
}).catch(() => {
@@ -72,6 +99,8 @@ route.app.get('/:imgName',
})
])).filter(val => val !== null);
if (query.sig) c.res.headers.set('content-signature', "key-id=KEY:RSA:p1.rec.net; data=aGk=");
if (datas.length == 0) return statusResponse(c, HTTPStatus.NotFound);
else {
const result = await convertImage(query, datas[0]);

View File

@@ -26,7 +26,7 @@ export class PlatformsManager extends ServerContentBase {
async getToken(prof: Profile, type: TokenType) {
const secret = env["SECRET"];
if (!secret) throw new Error("No SECRET in env. Did you forget to set it?");
const exp = type == TokenType.Access ? Math.round(Date.now() / 1000) + 21_600 : Math.round(Date.now() / 1000) + 31_556_952;
const exp = type == TokenType.Access ? Math.round(Date.now() / 1000) + 21_600 : Math.round(Date.now() / 1000) + 21_600;
const token: TokenFormat = {
typ: type,

View File

@@ -2,10 +2,8 @@ export enum TokenType {
Access,
Refresh
}
export interface TokenFormatBase {
export interface TokenFormat {
typ: TokenType
}
export interface TokenFormat extends TokenFormatBase {
iss: string,
exp: number,
iat: number,

View File

@@ -13,7 +13,7 @@ export class ProfileMatchmakingManager extends ProfileContentManager {
#loginLockKey = this.profile.constructProfilePropertyKey('loginlock');
async setLoginLock(lock: string) {
await this.kv.getKv().set(this.#deviceClassKey, lock);
await this.kv.getKv().set(this.#loginLockKey, lock);
}
async getLoginLock(): Promise<string | null> {
return (await this.kv.getKv().get<string>(this.#loginLockKey)).value;

View File

@@ -4,8 +4,9 @@ import Profile from "./profile.ts";
import { SelfAccount, type RecNetAccount } from "./types/profile.ts";
import Command from "./../commands/command.ts";
import z from "zod";
import { PlatformMask, ProfileRole, TokenType } from "../platforms/types.ts";
import { PlatformMask, PlatformType, ProfileRole, TokenType } from "../platforms/types.ts";
import Logging from "@proxnet/undead-logging";
import { BitFlags } from "../../util/flags.ts";
const profiles: Map<number, Profile> = new Map();
@@ -45,7 +46,7 @@ class ProfileManagerBase extends ServerContentBase {
else return await this.#getUnusedUsername();
}
async create(platform: number, platformId: string, username?: string, id?: number) {
async create(platform: PlatformType, mask: BitFlags<PlatformMask>, platformId: string, username?: string, id?: number) {
if (typeof id == 'number') {
const prof = await this.get(id);
if (prof) throw new Error("ID is in use");
@@ -60,14 +61,14 @@ class ProfileManagerBase extends ServerContentBase {
accountId: newId,
username: newUsername,
displayName: newUsername,
platforms: PlatformMask.None,
platforms: mask.getValue(),
profileImage: "DefaultProfileImage.png",
createdAt: new Date()
}
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newId ], newProfile);
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newUsername ], newId);
await this.server.Platforms.addCachedLogin(platform, platformId, newId);
if (platformId !== "") await this.server.Platforms.addCachedLogin(platform, platformId, newId);
return this.get(newId);
}

View File

@@ -39,6 +39,16 @@ export class ServerRoomsBase extends ServerContentBase {
if (agrooms.value !== null) this.#agroomIds = agrooms.value;
this.#log.i(`${this.#agroomIds.size} AG rooms exist`);
if (this.getAgRoomIds().size === 0) {
try {
const config = await this.tryGetAgRoomRuntimeConfig();
this.#log.i(`Starting AG room initialization`);
this.initBuiltinRooms(config.Rooms, config.Locations);
} catch (err) {
this.#log.e(`Could not run AG room initialization: ${(err as Error).stack}`);
}
}
const baserooms = await this.kv.getKv().get<Set<number>>([ServerRoomsBase.baseRoomIdsKey]);
if (baserooms.value !== null) this.#baseroomIds = baserooms.value;
@@ -52,6 +62,8 @@ export class ServerRoomsBase extends ServerContentBase {
key: ["initag", "initagrooms", "initagroom", "iag"],
zod: z.tuple([]).rest(z.string()),
exec: async (...arrayPath: string[]) => {
if (this.#agroomIds.size !== 0) return new Error("AG rooms already initialized");
const path = arrayPath.join(' ');
try {
const config = JSON.parse((await Deno.readTextFile(path)).toString()) as AGRoomRuntimeConfig;
@@ -105,6 +117,9 @@ export class ServerRoomsBase extends ServerContentBase {
return this.#agRoomRuntimeConfig;
}
async tryGetAgRoomRuntimeConfig() {
return JSON.parse(await Deno.readTextFile(`${RootPath}/res/rooms.json`)) as AGRoomRuntimeConfig;
}
async initBuiltinRooms(rooms: AGRoom[], locations: AGRoomLocation[]) {
await Promise.all(rooms.map(async room => {
if (room.Accessibility == RoomDataTypes.RoomAccessibility.Private) return;
@@ -115,10 +130,7 @@ export class ServerRoomsBase extends ServerContentBase {
"ARRoom",
"Registration",
"DormRoom"
].includes(room.Name)) {
this.#log.w(`Room '${room.Name}' is not eligible for builtin room generation`);
return;
}
].includes(room.Name)) return;
const roomFactory = await this.write();
if (roomFactory == null) {
@@ -185,9 +197,9 @@ export class ServerRoomsBase extends ServerContentBase {
if (builtinScene) return builtinScene.SupportsJoinInProgress;
else {
if (!this.#joinInProgressLookup) throw new Error("JoinInProgress lookup table is not yet initialized");
const lookup = this.#joinInProgressLookup[subroomFactory.RoomSceneLocationId];
if (lookup) return lookup;
else return false;
const lookup = this.#joinInProgressLookup[subroomFactory.RoomSceneLocationId];
if (lookup) return lookup;
else return false;
}
}

View File

@@ -10,37 +10,42 @@
export class BitFlags<T extends number> {
private value: T;
constructor(initialValue: T | number = 0 as T) {
this.value = initialValue as T;
constructor(...initialValue: (T | number)[]) {
if (Array.isArray(initialValue)) {
// Combine multiple flags into one numeric value
this.value = initialValue.reduce((acc, flag) => acc | flag, 0) as T;
} else {
this.value = initialValue as T;
}
}
/** Get current numeric value */
public getValue(): T {
public getValue() {
return this.value;
}
/** Check if a specific flag is set */
public has(flag: T): boolean {
public has(flag: T) {
return (this.value & flag) === flag;
}
/** Add a flag */
public add(flag: T): void {
public add(flag: T) {
this.value = (this.value | flag) as T;
}
/** Remove a flag */
public remove(flag: T): void {
public remove(flag: T) {
this.value = (this.value & ~flag) as T;
}
/** Toggle a flag */
public toggle(flag: T): void {
public toggle(flag: T) {
this.value = (this.value ^ flag) as T;
}
/** Reset all flags */
public clear(): void {
public clear() {
this.value = 0 as T;
}
}

View File

@@ -4,6 +4,9 @@ import { z } from "zod";
import type { HonoEnv } from "./types.ts";
import { ZodSchema } from "zod/v4";
import { HTTPStatus } from "@oneday/http-status";
import Logging from "@proxnet/undead-logging";
const log = new Logging("Validation");
// thanks claude, this hurt my brain!
@@ -17,7 +20,10 @@ export const typedZValidator = <
recNetError?: string
) => {
return zValidator(target, schema, (result, c) => {
if (!result.success) {
log.w(`Validation failed: ${JSON.stringify(result.error)}`);
if (recNetError) return c.json({ success: false, error: recNetError }, httpOk == true ? HTTPStatus.OK : HTTPStatus.BadRequest);
else return c.json({ success: false, error: "Request validation failed" }, httpOk == true ? HTTPStatus.OK : HTTPStatus.BadRequest);
}