custom flag (2) (#1303)

## Description:

This PR implements the permission check logic.

Other related parts will be handled in a separate UI update.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors
This commit is contained in:
Aotumuri
2025-07-03 09:24:52 +09:00
committed by GitHub
parent a83c2bda97
commit 4dd6c9bac3
9 changed files with 713 additions and 101 deletions
+5 -1
View File
@@ -1,3 +1,4 @@
import { base64url } from "jose";
import type { TemplateResult } from "lit";
import { html, LitElement, render } from "lit";
import { customElement, query, state } from "lit/decorators.js";
@@ -369,7 +370,10 @@ export function generatePreviewDataUrl(
height?: number,
): string {
// Calculate canvas size
const decoder = new PatternDecoder(pattern ?? DEFAULT_PATTERN_B64);
const decoder = new PatternDecoder(
pattern ?? DEFAULT_PATTERN_B64,
base64url.decode,
);
const scaledWidth = decoder.scaledWidth();
const scaledHeight = decoder.scaledHeight();
+4
View File
@@ -17,6 +17,8 @@ export const CosmeticsSchema = z.object({
z.string(),
z.object({
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
color: z.record(
@@ -24,6 +26,8 @@ export const CosmeticsSchema = z.object({
z.object({
color: z.string(),
name: z.string(),
role_group: z.string().optional(),
flares: z.array(z.string()).optional(),
}),
),
}),
+5 -4
View File
@@ -1,5 +1,3 @@
import { base64url } from "jose";
export class PatternDecoder {
private bytes: Uint8Array;
@@ -7,8 +5,11 @@ export class PatternDecoder {
readonly width: number;
readonly scale: number;
constructor(base64: string) {
this.bytes = base64url.decode(base64);
constructor(
base64: string,
base64urlDecode: (input: Uint8Array | string) => Uint8Array,
) {
this.bytes = base64urlDecode(base64);
if (this.bytes.length < 3) {
throw new Error(
+2 -1
View File
@@ -1,3 +1,4 @@
import { base64url } from "jose";
import { z } from "zod/v4";
import quickChatData from "../../resources/QuickChat.json" with { type: "json" };
import {
@@ -190,7 +191,7 @@ export const RequiredPatternSchema = z
.refine(
(val) => {
try {
new PatternDecoder(val);
new PatternDecoder(val, base64url.decode);
return true;
} catch (e) {
console.error(JSON.stringify(e.message, null, 2));
+2 -1
View File
@@ -1,3 +1,4 @@
import { base64url } from "jose";
import { Config } from "../configuration/Config";
import { PatternDecoder } from "../PatternDecoder";
import { ClientID, GameID, Player } from "../Schemas";
@@ -163,7 +164,7 @@ export class PlayerView {
this.decoder =
this.cosmetics.pattern === undefined
? undefined
: new PatternDecoder(this.cosmetics.pattern);
: new PatternDecoder(this.cosmetics.pattern, base64url.decode);
}
patternDecoder(): PatternDecoder | undefined {
+95 -2
View File
@@ -2,7 +2,10 @@ import { Cosmetics } from "../core/CosmeticSchemas";
import { PatternDecoder } from "../core/PatternDecoder";
export class PrivilegeChecker {
constructor(private cosmetics: Cosmetics) {}
constructor(
private cosmetics: Cosmetics,
private b64urlDecode: (base64: string) => Uint8Array,
) {}
isPatternAllowed(
base64: string,
@@ -14,7 +17,7 @@ export class PrivilegeChecker {
if (found === undefined) {
try {
// Ensure that the pattern will not throw for clients
new PatternDecoder(base64);
new PatternDecoder(base64, this.b64urlDecode);
} catch (e) {
// Pattern is invalid
return "invalid";
@@ -55,4 +58,94 @@ export class PrivilegeChecker {
return "restricted";
}
isCustomFlagAllowed(
flag: string,
roles: readonly string[] | undefined,
flares: readonly string[] | undefined,
): true | "restricted" | "invalid" {
if (!flag.startsWith("!")) return "invalid";
const code = flag.slice(1);
if (!code) return "invalid";
const segments = code.split("_");
if (segments.length === 0) return "invalid";
const MAX_LAYERS = 6; // Maximum number of layers allowed
if (segments.length > MAX_LAYERS) return "invalid";
const superFlare = flares?.includes("flag:*") ?? false;
for (const segment of segments) {
const [layerKey, colorKey] = segment.split("-");
if (!layerKey || !colorKey) return "invalid";
const layer = this.cosmetics.flag.layers[layerKey];
const color = this.cosmetics.flag.color[colorKey];
if (!layer || !color) return "invalid";
// Super-flare bypasses all restrictions
if (superFlare) {
continue;
}
// Check layer restrictions
const layerSpec = layer;
let layerAllowed = false;
if (!layerSpec.role_group && !layerSpec.flares) {
layerAllowed = true;
} else {
// By role
if (layerSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[layerSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
layerAllowed = true;
}
}
// By flare
if (
layerSpec.flares &&
flares?.some((f) => layerSpec.flares?.includes(f))
) {
layerAllowed = true;
}
// By named flag:layer:{name}
if (flares?.includes(`flag:layer:${layerSpec.name}`)) {
layerAllowed = true;
}
}
// Check color restrictions
const colorSpec = color;
let colorAllowed = false;
if (!colorSpec.role_group && !colorSpec.flares) {
colorAllowed = true;
} else {
// By role
if (colorSpec.role_group) {
const allowedRoles =
this.cosmetics.role_groups[colorSpec.role_group] || [];
if (roles?.some((r) => allowedRoles.includes(r))) {
colorAllowed = true;
}
}
// By flare
if (
colorSpec.flares &&
flares?.some((f) => colorSpec.flares?.includes(f))
) {
colorAllowed = true;
}
// By named flag:color:{name}
if (flares?.includes(`flag:color:${colorSpec.name}`)) {
colorAllowed = true;
}
}
// If either part is restricted, block
if (!(layerAllowed && colorAllowed)) {
return "restricted";
}
}
return true;
}
}
+14 -2
View File
@@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import http from "http";
import ipAnonymize from "ip-anonymize";
import { base64url } from "jose";
import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
@@ -44,7 +45,7 @@ export function startWorker() {
const gm = new GameManager(config, log);
const privilegeChecker = new PrivilegeChecker(COSMETICS);
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
if (config.otelEnabled()) {
initWorkerMetrics(gm);
@@ -369,7 +370,18 @@ export function startWorker() {
// Check if the flag is allowed
if (clientMsg.flag !== undefined) {
// TODO: Implement custom flag validation
if (clientMsg.flag.startsWith("!")) {
const allowed = privilegeChecker.isCustomFlagAllowed(
clientMsg.flag,
roles,
flares,
);
if (allowed !== true) {
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
ws.close(1002, `Custom flag ${allowed}`);
return;
}
}
}
// Check if the pattern is allowed