mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 07:40:43 +00:00
Remove role based perms, fetch cosmetics.json from api (#1640)
## Description: * Fetch cosmetics.json from api * Remove all role based perms, we are only using flares now * Created Priviledge refresher which periodically polls /cosmetics.json endpoint. ## 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 have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -9,3 +9,4 @@ resources/.DS_Store
|
||||
.env*
|
||||
.DS_Store
|
||||
.clinic/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
Admin 1286738076386856991
|
||||
OG 1286743849707769936
|
||||
Creator 1286745100411473930
|
||||
Bots 1286910984702791711
|
||||
Challenger 1292157381496799264
|
||||
OG100 1314802550314237952
|
||||
Contributor 1314972008362020957
|
||||
Ping 1316444187276738612
|
||||
Server Booster 1319387513206345770
|
||||
Content Creator 1320961080750637076
|
||||
Beta Tester 1327125593791397929
|
||||
Early Access Supporter 1330243292306341969
|
||||
Mod 1338654590043820148
|
||||
Support Staff 1343759662545244296
|
||||
DevChatAccess 1345831753528377425
|
||||
Member 1347621713852235808
|
||||
Active Contributor 1354828445489692692
|
||||
Retired Staff 1355753028099117147
|
||||
Head Mod 1357747869742010661
|
||||
Money Haters 1359441841371480176
|
||||
Translator 1367345579272831128
|
||||
Head Translator 1367345660852174930
|
||||
Development Stream Ping 1369340951109304340
|
||||
Core Contributor 1370238576868200488
|
||||
+62
-126
@@ -1,149 +1,85 @@
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { COSMETICS } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
|
||||
import { getApiBase, getAuthHeader } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
interface StripeProduct {
|
||||
id: string;
|
||||
object: "product";
|
||||
active: boolean;
|
||||
created: number;
|
||||
description: string | null;
|
||||
images: string[];
|
||||
livemode: boolean;
|
||||
metadata: Record<string, string>;
|
||||
name: string;
|
||||
shippable: boolean | null;
|
||||
type: "good" | "service";
|
||||
updated: number;
|
||||
url: string | null;
|
||||
price: string;
|
||||
price_id: string;
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
name: string;
|
||||
key: string;
|
||||
roles: string[];
|
||||
price?: string;
|
||||
priceId?: string;
|
||||
lockedReason?: string;
|
||||
notShown?: boolean;
|
||||
}
|
||||
|
||||
export async function patterns(
|
||||
userMe: UserMeResponse | null,
|
||||
): Promise<Pattern[]> {
|
||||
const patterns: Pattern[] = Object.entries(COSMETICS.patterns).map(
|
||||
([key, patternData]) => {
|
||||
return {
|
||||
name: patternData.name,
|
||||
key,
|
||||
roles: patternData.role_group
|
||||
? (COSMETICS.role_groups[patternData.role_group] ?? [])
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const cosmetics = await getCosmetics();
|
||||
|
||||
const products = await listAllProducts();
|
||||
patterns.forEach((pattern) => {
|
||||
addRestrictions(pattern, userMe, products);
|
||||
});
|
||||
if (cosmetics === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patterns: Pattern[] = [];
|
||||
const playerFlares = new Set(userMe?.player.flares);
|
||||
|
||||
for (const name in cosmetics.patterns) {
|
||||
const patternData = cosmetics.patterns[name];
|
||||
const hasAccess = playerFlares.has(`pattern:${name}`);
|
||||
if (hasAccess) {
|
||||
// Remove product info because player already has access.
|
||||
patternData.product = null;
|
||||
patterns.push(patternData);
|
||||
} else if (patternData.product !== null) {
|
||||
// Player doesn't have access, but product is available for purchase.
|
||||
patterns.push(patternData);
|
||||
}
|
||||
// If player doesn't have access and product is null, don't show it.
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
function addRestrictions(
|
||||
pattern: Pattern,
|
||||
userMe: UserMeResponse | null,
|
||||
products: Map<string, StripeProduct>,
|
||||
) {
|
||||
if (userMe === null) {
|
||||
if (products.has(`pattern:${pattern.name}`)) {
|
||||
// Purchasable (flare-gated) patterns are shown as disabled
|
||||
pattern.lockedReason = translateText("territory_patterns.blocked.login");
|
||||
} else {
|
||||
// Role-gated patterns are not shown
|
||||
pattern.notShown = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const flares = userMe.player.flares ?? [];
|
||||
if (
|
||||
flares.includes("pattern:*") ||
|
||||
flares.includes(`pattern:${pattern.name}`)
|
||||
) {
|
||||
// Pattern is unlocked by flare
|
||||
return;
|
||||
}
|
||||
|
||||
const myRoles = userMe.player.roles ?? [];
|
||||
if (
|
||||
pattern.roles.some((authorizedRole) => myRoles.includes(authorizedRole))
|
||||
) {
|
||||
// Pattern is unlocked by role
|
||||
return;
|
||||
}
|
||||
|
||||
const product = products.get(`pattern:${pattern.name}`);
|
||||
if (product) {
|
||||
pattern.price = product.price;
|
||||
pattern.priceId = product.price_id;
|
||||
pattern.lockedReason = translateText("territory_patterns.blocked.purchase");
|
||||
return;
|
||||
}
|
||||
|
||||
// Pattern is locked by role group and not purchasable, don't show it.
|
||||
pattern.notShown = true;
|
||||
}
|
||||
|
||||
export async function handlePurchase(priceId: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/stripe/create-checkout-session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
authorization: getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: priceId,
|
||||
successUrl: `${window.location.href}purchase-success`,
|
||||
cancelUrl: `${window.location.href}purchase-cancel`,
|
||||
}),
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/stripe/create-checkout-session`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
authorization: getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId: priceId,
|
||||
|
||||
successUrl: `${window.location.href}purchase-success`,
|
||||
cancelUrl: `${window.location.href}purchase-cancel`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`Error purchasing pattern:${response.status} ${response.statusText}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
if (response.status === 401) {
|
||||
alert("You are not logged in. Please log in to purchase a pattern.");
|
||||
} else {
|
||||
alert("Something went wrong. Please try again later.");
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Purchase error:", error);
|
||||
alert("Something went wrong. Please try again later.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Stripe checkout
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
// Returns a map of flare -> product
|
||||
export async function listAllProducts(): Promise<Map<string, StripeProduct>> {
|
||||
async function getCosmetics(): Promise<Cosmetics | undefined> {
|
||||
try {
|
||||
const response = await fetch(`${getApiBase()}/stripe/products`);
|
||||
const response = await fetch(`${getApiBase()}/cosmetics.json`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
console.error(`HTTP error! status: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const products = (await response.json()) as StripeProduct[];
|
||||
const productMap = new Map<string, StripeProduct>();
|
||||
products.forEach((product) => {
|
||||
productMap.set(product.metadata.flare, product);
|
||||
});
|
||||
return productMap;
|
||||
const result = CosmeticsSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return;
|
||||
}
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch products:", error);
|
||||
return new Map();
|
||||
console.error("Error getting cosmetics:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import type { TemplateResult } from "lit";
|
||||
import { html, LitElement, render } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { Pattern } from "../core/CosmeticSchemas";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
import "./components/Difficulties";
|
||||
import "./components/Maps";
|
||||
import { handlePurchase, Pattern, patterns } from "./Cosmetics";
|
||||
import { handlePurchase, patterns } from "./Cosmetics";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("territory-patterns-modal")
|
||||
@@ -107,14 +108,14 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderTooltip(): TemplateResult | null {
|
||||
if (this.hoveredPattern && this.hoveredPattern.lockedReason) {
|
||||
if (this.hoveredPattern && this.hoveredPattern.product !== undefined) {
|
||||
return html`
|
||||
<div
|
||||
class="fixed z-[10000] px-3 py-2 rounded bg-black text-white text-sm pointer-events-none shadow-md"
|
||||
style="top: ${this.hoverPosition.y + 12}px; left: ${this.hoverPosition
|
||||
.x + 12}px;"
|
||||
>
|
||||
${this.hoveredPattern.lockedReason}
|
||||
${translateText("territory_patterns.blocked.purchase")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private renderPatternButton(pattern: Pattern): TemplateResult {
|
||||
const isSelected = this.selectedPattern === pattern.key;
|
||||
const isSelected = this.selectedPattern === pattern.pattern;
|
||||
|
||||
return html`
|
||||
<div style="flex: 0 1 calc(25% - 1rem); max-width: calc(25% - 1rem);">
|
||||
@@ -131,9 +132,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
${isSelected
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"}
|
||||
${pattern.lockedReason ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
${pattern.product !== null ? "opacity-50 cursor-not-allowed" : ""}"
|
||||
@click=${() =>
|
||||
!pattern.lockedReason && this.selectPattern(pattern.key)}
|
||||
pattern.product === null && this.selectPattern(pattern.pattern)}
|
||||
@mouseenter=${(e: MouseEvent) => this.handleMouseEnter(pattern, e)}
|
||||
@mousemove=${(e: MouseEvent) => this.handleMouseMove(e)}
|
||||
@mouseleave=${() => this.handleMouseLeave()}
|
||||
@@ -155,23 +156,23 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
"
|
||||
>
|
||||
${this.renderPatternPreview(
|
||||
pattern.key,
|
||||
pattern.pattern,
|
||||
this.buttonWidth,
|
||||
this.buttonWidth,
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
${pattern.priceId !== undefined && pattern.lockedReason
|
||||
${pattern.product !== null
|
||||
? html`
|
||||
<button
|
||||
class="w-full mt-2 px-3 py-1 bg-green-500 hover:bg-green-600 text-white text-xs font-medium rounded transition-colors"
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
handlePurchase(pattern.priceId!);
|
||||
handlePurchase(pattern.product!.priceId);
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.purchase")}
|
||||
(${pattern.price})
|
||||
(${pattern.product!.price})
|
||||
</button>
|
||||
`
|
||||
: null}
|
||||
@@ -183,7 +184,6 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
const buttons: TemplateResult[] = [];
|
||||
for (const pattern of this.patterns) {
|
||||
if (!this.showChocoPattern && pattern.name === "choco") continue;
|
||||
if (pattern.notShown === true) continue;
|
||||
|
||||
const result = this.renderPatternButton(pattern);
|
||||
buttons.push(result);
|
||||
@@ -243,6 +243,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
this.modalEl?.open();
|
||||
window.addEventListener("keydown", this.handleKeyDown);
|
||||
this.isActive = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public close() {
|
||||
@@ -332,7 +333,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
private handleMouseEnter(pattern: Pattern, event: MouseEvent) {
|
||||
if (pattern.lockedReason) {
|
||||
if (pattern.product !== null) {
|
||||
this.hoveredPattern = pattern;
|
||||
this.hoverPosition = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
+35
-30
@@ -1,36 +1,41 @@
|
||||
import { z } from "zod";
|
||||
import cosmetics_json from "../../resources/cosmetics/cosmetics.json" with { type: "json" };
|
||||
import { z } from "zod/v4";
|
||||
import { RequiredPatternSchema } from "./Schemas";
|
||||
|
||||
export const ProductSchema = z.object({
|
||||
productId: z.string(),
|
||||
priceId: z.string(),
|
||||
price: z.string(),
|
||||
});
|
||||
|
||||
const PatternSchema = z.object({
|
||||
name: z.string(),
|
||||
pattern: RequiredPatternSchema,
|
||||
product: ProductSchema.nullable(),
|
||||
});
|
||||
|
||||
// Schema for resources/cosmetics/cosmetics.json
|
||||
export const CosmeticsSchema = z.object({
|
||||
role_groups: z.record(z.string(), z.string().array().min(1)),
|
||||
patterns: z.record(
|
||||
RequiredPatternSchema,
|
||||
z.object({
|
||||
name: z.string(),
|
||||
role_group: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
flag: z.object({
|
||||
layers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
role_group: z.string().optional(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
color: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
color: z.string(),
|
||||
name: z.string(),
|
||||
role_group: z.string().optional(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
patterns: z.record(z.string(), PatternSchema),
|
||||
flag: z
|
||||
.object({
|
||||
layers: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
color: z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
color: z.string(),
|
||||
name: z.string(),
|
||||
flares: z.array(z.string()).optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type Cosmetics = z.infer<typeof CosmeticsSchema>;
|
||||
export const COSMETICS: Cosmetics = CosmeticsSchema.parse(cosmetics_json);
|
||||
export type Pattern = z.infer<typeof PatternSchema>;
|
||||
export type Product = z.infer<typeof ProductSchema>;
|
||||
|
||||
+15
-4
@@ -1,4 +1,4 @@
|
||||
import { COSMETICS } from "./CosmeticSchemas";
|
||||
import { Cosmetics } from "./CosmeticSchemas";
|
||||
|
||||
const ANIMATION_DURATIONS: Record<string, number> = {
|
||||
rainbow: 4000,
|
||||
@@ -11,7 +11,18 @@ const ANIMATION_DURATIONS: Record<string, number> = {
|
||||
water: 6200,
|
||||
};
|
||||
|
||||
export function renderPlayerFlag(flag: string, target: HTMLElement) {
|
||||
// TODO: Pass in cosmetics as a parameter when
|
||||
// remote cosmetics are implemented for custom flags
|
||||
export function renderPlayerFlag(
|
||||
flag: string,
|
||||
target: HTMLElement,
|
||||
cosmetics: Cosmetics | undefined = undefined,
|
||||
) {
|
||||
if (cosmetics === undefined) {
|
||||
console.warn("No cosmetics provided for flag", flag);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!flag.startsWith("!")) return;
|
||||
|
||||
const code = flag.slice("!".length);
|
||||
@@ -26,7 +37,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) {
|
||||
target.style.aspectRatio = "3/4";
|
||||
|
||||
for (const { layerKey, colorKey } of layers) {
|
||||
const layerName = COSMETICS.flag.layers[layerKey]?.name ?? layerKey;
|
||||
const layerName = cosmetics?.flag?.layers[layerKey]?.name ?? layerKey;
|
||||
|
||||
const mask = `/flags/custom/${layerName}.svg`;
|
||||
if (!mask) continue;
|
||||
@@ -38,7 +49,7 @@ export function renderPlayerFlag(flag: string, target: HTMLElement) {
|
||||
layer.style.width = "100%";
|
||||
layer.style.height = "100%";
|
||||
|
||||
const colorValue = COSMETICS.flag.color[colorKey]?.color ?? colorKey;
|
||||
const colorValue = cosmetics?.flag?.color[colorKey]?.color ?? colorKey;
|
||||
const isSpecial =
|
||||
!colorValue.startsWith("#") &&
|
||||
!/^([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(colorValue);
|
||||
|
||||
+43
-45
@@ -1,19 +1,36 @@
|
||||
import { Cosmetics } from "../core/CosmeticSchemas";
|
||||
import { Cosmetics, Pattern } from "../core/CosmeticSchemas";
|
||||
import { PatternDecoder } from "../core/PatternDecoder";
|
||||
|
||||
export class PrivilegeChecker {
|
||||
export interface PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid";
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid";
|
||||
}
|
||||
|
||||
export class PrivilegeCheckerImpl implements PrivilegeChecker {
|
||||
private b64ToPattern: Record<string, Pattern> = {};
|
||||
|
||||
constructor(
|
||||
private cosmetics: Cosmetics,
|
||||
private b64urlDecode: (base64: string) => Uint8Array,
|
||||
) {}
|
||||
) {
|
||||
for (const name in this.cosmetics.patterns) {
|
||||
const pattern = this.cosmetics.patterns[name];
|
||||
this.b64ToPattern[pattern.pattern] = pattern;
|
||||
}
|
||||
}
|
||||
|
||||
isPatternAllowed(
|
||||
base64: string,
|
||||
roles: readonly string[] | undefined,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
// Look for the pattern in the cosmetics.json config
|
||||
const found = this.cosmetics.patterns[base64];
|
||||
const found = this.b64ToPattern[base64];
|
||||
if (found === undefined) {
|
||||
try {
|
||||
// Ensure that the pattern will not throw for clients
|
||||
@@ -30,27 +47,9 @@ export class PrivilegeChecker {
|
||||
return "unlisted";
|
||||
}
|
||||
|
||||
const { role_group, name } = found;
|
||||
if (role_group === undefined) {
|
||||
// Pattern has no restrictions
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const groupName of role_group) {
|
||||
if (
|
||||
roles !== undefined &&
|
||||
roles.some((role) =>
|
||||
this.cosmetics.role_groups[groupName]?.includes(role),
|
||||
)
|
||||
) {
|
||||
// Player is in a role group for this pattern
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
flares !== undefined &&
|
||||
(flares.includes(`pattern:${name}`) || flares.includes("pattern:*"))
|
||||
(flares.includes(`pattern:${found.name}`) || flares.includes("pattern:*"))
|
||||
) {
|
||||
// Player has a flare for this pattern
|
||||
return true;
|
||||
@@ -61,7 +60,6 @@ export class PrivilegeChecker {
|
||||
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
roles: readonly string[] | undefined,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid" {
|
||||
if (!flag.startsWith("!")) return "invalid";
|
||||
@@ -78,8 +76,8 @@ export class PrivilegeChecker {
|
||||
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];
|
||||
const layer = this.cosmetics.flag?.layers[layerKey];
|
||||
const color = this.cosmetics.flag?.color[colorKey];
|
||||
if (!layer || !color) return "invalid";
|
||||
|
||||
// Super-flare bypasses all restrictions
|
||||
@@ -90,17 +88,9 @@ export class PrivilegeChecker {
|
||||
// Check layer restrictions
|
||||
const layerSpec = layer;
|
||||
let layerAllowed = false;
|
||||
if (!layerSpec.role_group && !layerSpec.flares) {
|
||||
if (!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 &&
|
||||
@@ -117,17 +107,9 @@ export class PrivilegeChecker {
|
||||
// Check color restrictions
|
||||
const colorSpec = color;
|
||||
let colorAllowed = false;
|
||||
if (!colorSpec.role_group && !colorSpec.flares) {
|
||||
if (!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 &&
|
||||
@@ -149,3 +131,19 @@ export class PrivilegeChecker {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
|
||||
isPatternAllowed(
|
||||
name: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "unlisted" | "invalid" {
|
||||
return true;
|
||||
}
|
||||
|
||||
isCustomFlagAllowed(
|
||||
flag: string,
|
||||
flares: readonly string[] | undefined,
|
||||
): true | "restricted" | "invalid" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { base64url } from "jose";
|
||||
import { Logger } from "winston";
|
||||
import { CosmeticsSchema } from "../core/CosmeticSchemas";
|
||||
import {
|
||||
FailOpenPrivilegeChecker,
|
||||
PrivilegeChecker,
|
||||
PrivilegeCheckerImpl,
|
||||
} from "./Privilege";
|
||||
|
||||
// Refreshes the privilege checker every 5 minutes.
|
||||
// WARNING: This fails open if cosmetics.json is not available.
|
||||
export class PrivilegeRefresher {
|
||||
private privilegeChecker: PrivilegeChecker | null = null;
|
||||
private failOpenPrivilegeChecker: PrivilegeChecker =
|
||||
new FailOpenPrivilegeChecker();
|
||||
|
||||
private log: Logger;
|
||||
|
||||
constructor(
|
||||
private endpoint: string,
|
||||
parentLog: Logger,
|
||||
private refreshInterval: number = 1000 * 60 * 3,
|
||||
) {
|
||||
this.log = parentLog.child({ comp: "privilege-refresher" });
|
||||
}
|
||||
|
||||
public async start() {
|
||||
this.log.info(
|
||||
`Starting privilege refresher with interval ${this.refreshInterval}`,
|
||||
);
|
||||
// Add some jitter to the initial load and the interval.
|
||||
setTimeout(() => this.loadPrivilegeChecker(), Math.random() * 1000);
|
||||
setInterval(
|
||||
() => this.loadPrivilegeChecker(),
|
||||
this.refreshInterval + Math.random() * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
public get(): PrivilegeChecker {
|
||||
return this.privilegeChecker ?? this.failOpenPrivilegeChecker;
|
||||
}
|
||||
|
||||
private async loadPrivilegeChecker(): Promise<void> {
|
||||
this.log.info(`Loading privilege checker from ${this.endpoint}`);
|
||||
try {
|
||||
const response = await fetch(this.endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const cosmeticsData = await response.json();
|
||||
const result = CosmeticsSchema.safeParse(cosmeticsData);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid cosmetics data: ${result.error.message}`);
|
||||
}
|
||||
|
||||
this.privilegeChecker = new PrivilegeCheckerImpl(
|
||||
result.data,
|
||||
base64url.decode,
|
||||
);
|
||||
this.log.info(`Privilege checker loaded successfully`);
|
||||
} catch (error) {
|
||||
this.log.error(`Failed to fetch cosmetics from ${this.endpoint}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-16
@@ -2,14 +2,12 @@ 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";
|
||||
import { z } from "zod";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { COSMETICS } from "../core/CosmeticSchemas";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
@@ -25,7 +23,8 @@ import { GameManager } from "./GameManager";
|
||||
import { gatekeeper, LimiterType } from "./Gatekeeper";
|
||||
import { getUserMe, verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
import { PrivilegeChecker } from "./Privilege";
|
||||
|
||||
import { PrivilegeRefresher } from "./PrivilegeRefresher";
|
||||
import { initWorkerMetrics } from "./WorkerMetrics";
|
||||
|
||||
const config = getServerConfigFromServer();
|
||||
@@ -34,7 +33,7 @@ const workerId = parseInt(process.env.WORKER_ID ?? "0");
|
||||
const log = logger.child({ comp: `w_${workerId}` });
|
||||
|
||||
// Worker setup
|
||||
export function startWorker() {
|
||||
export async function startWorker() {
|
||||
log.info(`Worker starting...`);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
@@ -46,12 +45,16 @@ export function startWorker() {
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
|
||||
const privilegeChecker = new PrivilegeChecker(COSMETICS, base64url.decode);
|
||||
|
||||
if (config.otelEnabled()) {
|
||||
initWorkerMetrics(gm);
|
||||
}
|
||||
|
||||
const privilegeRefresher = new PrivilegeRefresher(
|
||||
config.jwtIssuer() + "/cosmetics.json",
|
||||
log,
|
||||
);
|
||||
privilegeRefresher.start();
|
||||
|
||||
// Middleware to handle /wX path prefix
|
||||
app.use((req, res, next) => {
|
||||
// Extract the original path without the worker prefix
|
||||
@@ -396,11 +399,9 @@ export function startWorker() {
|
||||
// Check if the flag is allowed
|
||||
if (clientMsg.flag !== undefined) {
|
||||
if (clientMsg.flag.startsWith("!")) {
|
||||
const allowed = privilegeChecker.isCustomFlagAllowed(
|
||||
clientMsg.flag,
|
||||
roles,
|
||||
flares,
|
||||
);
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isCustomFlagAllowed(clientMsg.flag, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`);
|
||||
ws.close(1002, `Custom flag ${allowed}`);
|
||||
@@ -411,11 +412,9 @@ export function startWorker() {
|
||||
|
||||
// Check if the pattern is allowed
|
||||
if (clientMsg.pattern !== undefined) {
|
||||
const allowed = privilegeChecker.isPatternAllowed(
|
||||
clientMsg.pattern,
|
||||
roles,
|
||||
flares,
|
||||
);
|
||||
const allowed = privilegeRefresher
|
||||
.get()
|
||||
.isPatternAllowed(clientMsg.pattern, flares);
|
||||
if (allowed !== true) {
|
||||
log.warn(`Pattern ${allowed}: ${clientMsg.pattern}`);
|
||||
ws.close(1002, `Pattern ${allowed}`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Cosmetics } from "../../src/core/CosmeticSchemas";
|
||||
import { PrivilegeChecker } from "../../src/server/Privilege";
|
||||
import { PrivilegeCheckerImpl } from "../../src/server/Privilege";
|
||||
|
||||
describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
|
||||
const dummyPatternDecoder = (_base64: string) => {
|
||||
@@ -7,181 +7,89 @@ describe("PrivilegeChecker.isCustomFlagAllowed (with mock cosmetics)", () => {
|
||||
};
|
||||
|
||||
const mockCosmetics: Cosmetics = {
|
||||
role_groups: {
|
||||
donor: ["role_donor"],
|
||||
admin: ["role_admin"],
|
||||
},
|
||||
patterns: {},
|
||||
flag: {
|
||||
layers: {
|
||||
a: {
|
||||
name: "chocolate",
|
||||
role_group: "donor",
|
||||
flares: ["cosmetic:flags"],
|
||||
},
|
||||
b: { name: "center_hline" },
|
||||
c: { name: "admin_layer", role_group: "admin" },
|
||||
c: { name: "admin_layer" },
|
||||
},
|
||||
color: {
|
||||
a: { color: "#ff0000", name: "red", role_group: "admin" },
|
||||
a: { color: "#ff0000", name: "red", flares: ["cosmetic:red"] },
|
||||
b: { color: "#00ff00", name: "green" },
|
||||
c: { color: "#0000ff", name: "blue", flares: ["cosmetic:blue"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const checker = new PrivilegeChecker(mockCosmetics, dummyPatternDecoder);
|
||||
const checker = new PrivilegeCheckerImpl(mockCosmetics, dummyPatternDecoder);
|
||||
|
||||
it("allowed: unrestricted layer/color", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-b", [], [])).toBe(true);
|
||||
});
|
||||
|
||||
it("restricted: donor layer without role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-b", [], [])).toBe("restricted");
|
||||
});
|
||||
|
||||
it("allowed: donor layer with donor role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-b", ["role_donor"], [])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!b-b", [])).toBe(true);
|
||||
});
|
||||
|
||||
it("allowed: donor layer with correct flare", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-b", [], ["cosmetic:flags"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("restricted: admin color without role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-a", [], [])).toBe("restricted");
|
||||
});
|
||||
|
||||
it("allowed: admin color with admin role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-a", ["role_admin"], [])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!a-b", ["cosmetic:flags"])).toBe(true);
|
||||
});
|
||||
|
||||
it("allowed: color with correct flare", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-c", [], ["cosmetic:blue"])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!b-c", ["cosmetic:blue"])).toBe(true);
|
||||
});
|
||||
|
||||
it("invalid: non-existent layer", () => {
|
||||
expect(checker.isCustomFlagAllowed("!zzz-a", ["role_donor"], [])).toBe(
|
||||
"invalid",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!zzz-a", [])).toBe("invalid");
|
||||
});
|
||||
|
||||
it("invalid: non-existent color", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-zzz", ["role_donor"], [])).toBe(
|
||||
"invalid",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!a-zzz", [])).toBe("invalid");
|
||||
});
|
||||
|
||||
it("allowed: superFlare allows all listed", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-a", [], ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!b-b", [], ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!c-a", [], ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!a-c", [], ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!a-a", ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!b-b", ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!c-a", ["flag:*"])).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!a-c", ["flag:*"])).toBe(true);
|
||||
});
|
||||
|
||||
it("invalid: superFlare does not allow non-existent", () => {
|
||||
expect(checker.isCustomFlagAllowed("!zzz-zzz", [], ["flag:*"])).toBe(
|
||||
"invalid",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!zzz-zzz", ["flag:*"])).toBe("invalid");
|
||||
});
|
||||
it("allowed: flare flag:layer:chocolate allows chocolate layer", () => {
|
||||
expect(
|
||||
checker.isCustomFlagAllowed("!a-b", [], ["flag:layer:chocolate"]),
|
||||
).toBe(true);
|
||||
});
|
||||
it("allowed: flare flag:color:blue allows blue color", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-c", [], ["flag:color:blue"])).toBe(
|
||||
expect(checker.isCustomFlagAllowed("!a-b", ["flag:layer:chocolate"])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allowed: flare flag:color:blue allows blue color", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-c", ["flag:color:blue"])).toBe(true);
|
||||
});
|
||||
it("restricted: only color flare, layer still restricted", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
|
||||
expect(checker.isCustomFlagAllowed("!a-c", ["cosmetic:blue"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
it("restricted: only layer flare, color still restricted", () => {
|
||||
expect(checker.isCustomFlagAllowed("!c-a", [], ["cosmetic:flags"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
it("allowed: layer by role, color by flare", () => {
|
||||
// layer a: role_group donor, color c: flares ["cosmetic:blue"]
|
||||
expect(
|
||||
checker.isCustomFlagAllowed("!a-c", ["role_donor"], ["cosmetic:blue"]),
|
||||
).toBe(true);
|
||||
});
|
||||
it("restricted: layer by role, color by flare (missing flare)", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-c", ["role_donor"], [])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
it("restricted: layer by role, color by flare (missing role)", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-c", [], ["cosmetic:blue"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
it("allowed: layer by flare, color by role", () => {
|
||||
// layer a: flares ["cosmetic:flags"], color a: role_group admin
|
||||
expect(
|
||||
checker.isCustomFlagAllowed("!a-a", ["role_admin"], ["cosmetic:flags"]),
|
||||
).toBe(true);
|
||||
});
|
||||
it("restricted: layer by flare, color by role (missing flare)", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-a", ["role_admin"], [])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
it("restricted: layer by flare, color by role (missing role)", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-a", [], ["cosmetic:flags"])).toBe(
|
||||
expect(checker.isCustomFlagAllowed("!c-a", ["cosmetic:flags"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
|
||||
it("allowed: two segments, both unrestricted", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-b_b-b", [], [])).toBe(true);
|
||||
});
|
||||
it("restricted: two segments, one restricted by layer role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!a-b_b-b", [], [])).toBe("restricted");
|
||||
expect(checker.isCustomFlagAllowed("!a-b_b-b", ["role_donor"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("restricted: two segments, one restricted by color role", () => {
|
||||
expect(checker.isCustomFlagAllowed("!b-a_b-b", [], [])).toBe("restricted");
|
||||
expect(checker.isCustomFlagAllowed("!b-a_b-b", ["role_admin"], [])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("allowed: two segments, one by role, one by flare", () => {
|
||||
expect(
|
||||
checker.isCustomFlagAllowed(
|
||||
"!a-c_b-b",
|
||||
["role_donor"],
|
||||
["cosmetic:blue"],
|
||||
),
|
||||
).toBe(true);
|
||||
expect(checker.isCustomFlagAllowed("!a-c_b-b", ["role_donor"], [])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!a-c_b-b", [], ["cosmetic:blue"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!b-b_b-b", [])).toBe(true);
|
||||
});
|
||||
it("allowed: two segments, both by flare", () => {
|
||||
expect(
|
||||
checker.isCustomFlagAllowed(
|
||||
"!a-c_a-c",
|
||||
[],
|
||||
["cosmetic:flags", "cosmetic:blue"],
|
||||
),
|
||||
checker.isCustomFlagAllowed("!a-c_a-c", [
|
||||
"cosmetic:flags",
|
||||
"cosmetic:blue",
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:flags"]),
|
||||
).toBe("restricted");
|
||||
expect(checker.isCustomFlagAllowed("!a-c_a-c", [], ["cosmetic:blue"])).toBe(
|
||||
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:flags"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
expect(checker.isCustomFlagAllowed("!a-c_a-c", ["cosmetic:blue"])).toBe(
|
||||
"restricted",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user