Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ PORT=3001
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb
TOKEN_AUTH=changeme
CORS_ORIGIN=http://localhost:3000

# Game Data Administration & Map Builder
GAME_DATA_ADMIN_EMAIL=admin@aoweb.app
GAME_DATA_ADMIN_ACCOUNT_ID=
GAME_DATA_ADMIN_PROXY_TOKEN=
42 changes: 42 additions & 0 deletions api/src/repositories/__tests__/worldBuilderPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import {
PROTECTED_MAPS,
isMapProtected,
canAccountEditMap,
} from "../worldBuilder";

describe("World Builder Map Permissions and Protections (#4)", () => {
it("should identify city maps as protected by default", () => {
expect(isMapProtected(1)).toBe(true); // Ullathorpe
expect(isMapProtected(34)).toBe(true); // Nix
expect(isMapProtected(59)).toBe(true); // Banderbill
expect(isMapProtected(50)).toBe(false); // Regular map
});

it("should reject edits to protected maps when override is false", () => {
const result = canAccountEditMap("admin_123", 1, true, undefined, false);
expect(result.allowed).toBe(false);
expect(result.reason).toContain("protegido contra modificaciones");
});

it("should allow edits to protected maps when override is true", () => {
const result = canAccountEditMap("admin_123", 1, true, undefined, true);
expect(result.allowed).toBe(true);
});

it("should allow superadmin to edit non-protected maps", () => {
const result = canAccountEditMap("admin_123", 50, true, undefined, false);
expect(result.allowed).toBe(true);
});

it("should allow collaborator to edit specifically assigned map", () => {
const result = canAccountEditMap("collab_456", 50, false, [50, 51], false);
expect(result.allowed).toBe(true);
});

it("should reject collaborator attempting to edit unassigned map", () => {
const result = canAccountEditMap("collab_456", 100, false, [50, 51], false);
expect(result.allowed).toBe(false);
expect(result.reason).toContain("no tiene permisos");
});
});
100 changes: 85 additions & 15 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,49 @@ import { validatePngUpload } from "../lib/pngValidation";
*/
export const UPLOADED_GRAPHIC_INDEX_START = 1_000_000;

/** Ultimo indice de grafico incluido no engine original. */
export const MAX_ENGINE_GRAPHIC_INDEX = 320_151;

/** Los mapas del juego son de 100x100. */
export const MAP_SIZE = 100;

/**
* Mapas protegidos contra modificaciones destructivas (ciudades principales).
* Requieren autorizacion explicita con override para ser modificados.
*/
export const PROTECTED_MAPS = new Set<number>([1, 34, 59, 60, 61]);

export function isMapProtected(mapNum: number): boolean {
return PROTECTED_MAPS.has(mapNum);
}

export function canAccountEditMap(
accountId: string,
mapNum: number,
isSuperAdmin: boolean,
allowedMapsForAccount?: number[],
allowProtectedOverride = false,
): { allowed: boolean; reason?: string } {
if (isMapProtected(mapNum) && !allowProtectedOverride) {
return {
allowed: false,
reason: `El mapa ${mapNum} esta protegido contra modificaciones.`,
};
}

if (isSuperAdmin) {
return { allowed: true };
}

if (allowedMapsForAccount && allowedMapsForAccount.includes(mapNum)) {
return { allowed: true };
}

return {
allowed: false,
reason: `La cuenta ${accountId} no tiene permisos para editar el mapa ${mapNum}.`,
};
}
/** Un tile admite un objeto y un NPC, como el modelo del juego. */
export const TILE_ENTITY_KINDS = ["obj", "npc"] as const;

Expand Down Expand Up @@ -195,6 +235,44 @@ export async function listGraphics(limit = 100): Promise<UploadedGraphic[]> {
}));
}

export const paletteEntrySchema = z.object({
graphics: z.array(z.number().int().positive()).min(1).max(4),
blocked: z.boolean().optional(),
});

export type PaletteEntry = z.infer<typeof paletteEntrySchema>;

/**
* Valida que los graficos de una entrada de paleta existan (originales o subidos).
*/
export async function validatePaletteEntry(
entry: PaletteEntry,
): Promise<{ valid: boolean; reason?: string }> {
for (const grhIndex of entry.graphics) {
if (grhIndex >= UPLOADED_GRAPHIC_INDEX_START) {
const exists = await pool.query(
`SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`,
[grhIndex],
);
if (exists.rowCount === 0) {
return {
valid: false,
reason: `El grafico ${grhIndex} no existe en el motor ni en assets subidos.`,
};
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
} else if (
grhIndex <= 0 ||
grhIndex > MAX_ENGINE_GRAPHIC_INDEX
) {
return {
valid: false,
Comment thread
gitar-bot[bot] marked this conversation as resolved.
reason: `Indice de grafico invalido: ${grhIndex}.`,
};
}
}
return { valid: true };
}

export const tilePaintSchema = z.object({
x: z.coerce.number().int().min(1).max(MAP_SIZE),
y: z.coerce.number().int().min(1).max(MAP_SIZE),
Expand Down Expand Up @@ -246,21 +324,13 @@ export async function paintTiles(
await client.query("BEGIN");

for (const tile of tiles) {
// Un grafico referenciado tiene que existir: o es uno original del
// juego (por debajo del rango de subidos) o uno que subimos.
if (
tile.grhIndex != null &&
tile.grhIndex >= UPLOADED_GRAPHIC_INDEX_START
) {
const exists = await client.query(
`SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`,
[tile.grhIndex],
);

if (exists.rowCount === 0) {
throw new Error(
`El grafico ${tile.grhIndex} no existe. Subilo antes de usarlo.`,
);
if (tile.grhIndex != null) {
const validation = await validatePaletteEntry({
graphics: [tile.grhIndex],
});

if (!validation.valid) {
throw new Error(validation.reason);
}
}

Expand Down
72 changes: 72 additions & 0 deletions api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,13 @@ import {
upsertGameBalance,
} from "./repositories/gameBalance";
import {
canAccountEditMap,
clearTile,
discardDrafts,
getGraphicContent,
getMapStatus,
getMapTerrainPalette,
isMapProtected,
listGraphics,
listMapOverrides,
listMapTileEntities,
Expand Down Expand Up @@ -863,6 +865,20 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => {
return;
}

const allowOverride = request.headers["x-protected-map-override"] === "true";
const permission = canAccountEditMap(
authorized.session.account._id,
mapNum,
true,
undefined,
allowOverride,
);

if (!permission.allowed) {
response.status(403).json({ error: permission.reason });
return;
}

const parsed = paintTilesSchema.safeParse(request.body);

if (!parsed.success) {
Expand Down Expand Up @@ -906,6 +922,20 @@ app.delete(
return;
}

const allowOverride = request.headers["x-protected-map-override"] === "true";
const permission = canAccountEditMap(
authorized.session.account._id,
mapNum,
true,
undefined,
allowOverride,
);

if (!permission.allowed) {
response.status(403).json({ error: permission.reason });
return;
}

response.json({ removed: await clearTile(mapNum, x, y, layer) });
} catch (error) {
const message =
Expand Down Expand Up @@ -1026,6 +1056,20 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => {
return;
}

const allowOverride = request.headers["x-protected-map-override"] === "true";
const permission = canAccountEditMap(
authorized.session.account._id,
mapNum,
true,
undefined,
allowOverride,
);

if (!permission.allowed) {
response.status(403).json({ error: permission.reason });
return;
}

response.json(
await publishMap(mapNum, authorized.session.account._id),
);
Expand All @@ -1049,6 +1093,20 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => {
return;
}

const allowOverride = request.headers["x-protected-map-override"] === "true";
const permission = canAccountEditMap(
authorized.session.account._id,
mapNum,
true,
undefined,
allowOverride,
);

if (!permission.allowed) {
response.status(403).json({ error: permission.reason });
return;
}

response.json(await discardDrafts(mapNum));
} catch (error) {
const message =
Expand All @@ -1073,6 +1131,20 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => {
return;
}

const allowOverride = request.headers["x-protected-map-override"] === "true";
const permission = canAccountEditMap(
authorized.session.account._id,
mapNum,
true,
undefined,
allowOverride,
);

if (!permission.allowed) {
response.status(403).json({ error: permission.reason });
return;
}

response.json(await revertMap(mapNum));
} catch (error) {
const message =
Expand Down
97 changes: 97 additions & 0 deletions api/src/tests/paletteValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MAX_ENGINE_GRAPHIC_INDEX,
paletteEntrySchema,
UPLOADED_GRAPHIC_INDEX_START,
validatePaletteEntry,
} from "../repositories/worldBuilder";
import pool from "../db";

afterEach(() => {
vi.restoreAllMocks();
});

describe("Palette Entry Schema and Validation (#6)", () => {
it("should accept valid multi-layer palette entries with blocking flag", () => {
const valid = paletteEntrySchema.safeParse({
graphics: [5500, 581],
blocked: true,
});
expect(valid.success).toBe(true);
if (valid.success) {
expect(valid.data.graphics).toEqual([5500, 581]);
expect(valid.data.blocked).toBe(true);
}
});

it("should reject palette entries with empty graphics array", () => {
const invalid = paletteEntrySchema.safeParse({
graphics: [],
blocked: false,
});
expect(invalid.success).toBe(false);
});

it("should reject palette entries exceeding maximum layers (4)", () => {
const invalid = paletteEntrySchema.safeParse({
graphics: [1, 2, 3, 4, 5],
});
expect(invalid.success).toBe(false);
});

it("should enforce non-colliding reserved range for uploaded graphics", () => {
expect(UPLOADED_GRAPHIC_INDEX_START).toBe(1_000_000);
// Original game graphics reach up to 320151, well below 1_000_000
expect(UPLOADED_GRAPHIC_INDEX_START).toBeGreaterThan(
MAX_ENGINE_GRAPHIC_INDEX,
);
});

it("should accept an original engine graphic without a database lookup", async () => {
const query = vi.spyOn(pool, "query");
const result = await validatePaletteEntry({
graphics: [MAX_ENGINE_GRAPHIC_INDEX],
});

expect(result).toEqual({ valid: true });
expect(query).not.toHaveBeenCalled();
});

it("should reject graphic indices outside the original engine range", async () => {
const query = vi.spyOn(pool, "query");
const result = await validatePaletteEntry({
graphics: [MAX_ENGINE_GRAPHIC_INDEX + 1],
});

expect(result.valid).toBe(false);
expect(result.reason).toContain("Indice de grafico invalido");
expect(query).not.toHaveBeenCalled();
});

it("should validate uploaded graphics against the database", async () => {
const query = vi
.spyOn(pool, "query")
.mockResolvedValue({ rowCount: 1 } as never);

const result = await validatePaletteEntry({
graphics: [UPLOADED_GRAPHIC_INDEX_START],
});

expect(result).toEqual({ valid: true });
expect(query).toHaveBeenCalledWith(
expect.stringContaining("game_uploaded_graphics"),
[UPLOADED_GRAPHIC_INDEX_START],
);
});

it("should reject an uploaded graphic that is not registered", async () => {
vi.spyOn(pool, "query").mockResolvedValue({ rowCount: 0 } as never);

const result = await validatePaletteEntry({
graphics: [UPLOADED_GRAPHIC_INDEX_START + 1],
});

expect(result.valid).toBe(false);
expect(result.reason).toContain("no existe");
});
});
Loading