diff --git a/api/.env.example b/api/.env.example index 1d568703..b1f0cd35 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,3 +2,8 @@ PORT=3001 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/aoweb TOKEN_AUTH=changeme CORS_ORIGIN=http://localhost:3000 + +# Game Data Admin Configuration (World Builder & Content Management) +GAME_DATA_ADMIN_EMAIL=admin@aoweb.app +GAME_DATA_ADMIN_ACCOUNT_ID= +GAME_DATA_ADMIN_PROXY_TOKEN=secret-proxy-token diff --git a/api/schema.sql b/api/schema.sql index c5103206..81355777 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -627,3 +627,16 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_overrides_map ON game_map_tile_overrides(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +-- Permisos granulares de edicion de mapa por cuenta. +-- map_num = 0 indica permiso de edicion global sobre mapas no protegidos. +CREATE TABLE IF NOT EXISTS game_map_permissions ( + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + map_num INTEGER NOT NULL CHECK (map_num >= 0), + granted_by UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (account_id, map_num) +); + +CREATE INDEX IF NOT EXISTS idx_game_map_permissions_account_map + ON game_map_permissions(account_id, map_num); diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 8fd58c95..d37e2281 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -417,3 +417,101 @@ export async function clearTile( return (result.rowCount ?? 0) > 0; } + +/** + * Mapas principales protegidos contra edición accidental o no autorizada. + * Incluye las ciudades principales (Ullathorpe = 1, Nix = 34, Banderbill = 59, Lindos = 150). + */ +export const PROTECTED_MAPS: ReadonlySet = new Set([1, 34, 59, 150]); + +export function isProtectedMap(mapNum: number): boolean { + return PROTECTED_MAPS.has(mapNum); +} + +export type MapPermissionCheckResult = + | { allowed: true } + | { allowed: false; reason: string }; + +/** + * Verifica si una cuenta tiene permisos para editar un mapa específico. + * + * 1. Los administradores globales pueden editar mapas no protegidos, o protegidos si envían `overrideProtected: true`. + * 2. Los colaboradores deben tener asignado el mapa en `game_map_permissions` y no pueden editar mapas protegidos. + */ +export async function checkMapEditPermission(options: { + accountId: string; + isSuperAdmin: boolean; + mapNum: number; + overrideProtected?: boolean; +}): Promise { + const { accountId, isSuperAdmin, mapNum, overrideProtected } = options; + + if (isSuperAdmin) { + if (isProtectedMap(mapNum) && !overrideProtected) { + return { + allowed: false, + reason: `El mapa ${mapNum} esta protegido contra edicion accidental. Para modificarlo como admin debes especificar overrideProtected = true.`, + }; + } + return { allowed: true }; + } + + // Colaboradores regulares: nunca pueden modificar mapas protegidos + if (isProtectedMap(mapNum)) { + return { + allowed: false, + reason: `El mapa ${mapNum} esta protegido. Los colaboradores no tienen permisos de modificacion sobre mapas protegidos.`, + }; + } + + // Verificar si tiene permiso granular concedido (map_num exacto o map_num = 0 para permiso global) + const permission = await pool.query<{ map_num: number }>( + `SELECT map_num FROM game_map_permissions + WHERE account_id = $1 AND (map_num = $2 OR map_num = 0) + LIMIT 1`, + [accountId, mapNum], + ); + + if (permission.rowCount === 0) { + return { + allowed: false, + reason: `La cuenta ${accountId} no tiene permisos para editar el mapa ${mapNum}.`, + }; + } + + return { allowed: true }; +} + +export async function grantMapPermission( + accountId: string, + mapNum: number, + grantedByAccountId: string, +): Promise { + await pool.query( + `INSERT INTO game_map_permissions (account_id, map_num, granted_by, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (account_id, map_num) DO NOTHING`, + [accountId, mapNum, grantedByAccountId], + ); +} + +export async function revokeMapPermission( + accountId: string, + mapNum: number, +): Promise { + const result = await pool.query( + `DELETE FROM game_map_permissions WHERE account_id = $1 AND map_num = $2`, + [accountId, mapNum], + ); + return (result.rowCount ?? 0) > 0; +} + +export async function listAccountMapPermissions( + accountId: string, +): Promise { + const result = await pool.query<{ map_num: number }>( + `SELECT map_num FROM game_map_permissions WHERE account_id = $1 ORDER BY map_num`, + [accountId], + ); + return result.rows.map((row) => row.map_num); +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..ecc7e789 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -96,16 +96,21 @@ import { upsertGameBalance, } from "./repositories/gameBalance"; import { + checkMapEditPermission, clearTile, discardDrafts, getGraphicContent, getMapStatus, + grantMapPermission, + isProtectedMap, + listAccountMapPermissions, listGraphics, listMapOverrides, paintTiles, paintTilesSchema, publishMap, revertMap, + revokeMapPermission, uploadGraphic, } from "./repositories/worldBuilder"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; @@ -233,6 +238,46 @@ async function requireAdminEmailSession( return authorized; } +async function requireMapEditSession( + request: express.Request, + response: express.Response, + mapNum: number, + overrideProtected = false, +): Promise<{ accountId: string; isSuperAdmin: boolean } | null> { + if ( + !config.gameDataAdminProxyToken || + getGameDataAdminProxyHeader(request) !== config.gameDataAdminProxyToken + ) { + response.status(403).json({ error: "No autorizado." }); + return null; + } + + const authorized = await getAuthorizedSession(request); + + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return null; + } + + const isSuperAdmin = isAuthorizedGameDataAdmin(authorized.session); + const permission = await checkMapEditPermission({ + accountId: authorized.session.account._id, + isSuperAdmin, + mapNum, + overrideProtected, + }); + + if (!permission.allowed) { + response.status(403).json({ error: permission.reason }); + return null; + } + + return { + accountId: authorized.session.account._id, + isSuperAdmin, + }; +} + async function ensurePgStatStatements(): Promise { try { await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements"); @@ -847,9 +892,6 @@ app.get("/game-data/graphics/:grhIndex.png", async (request, response) => { app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -857,6 +899,15 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + const parsed = paintTilesSchema.safeParse(request.body); if (!parsed.success) { @@ -870,7 +921,7 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { await paintTiles( mapNum, parsed.data.tiles, - authorized.session.account._id, + authorized.accountId, ), ); } catch (error) { @@ -884,12 +935,6 @@ app.delete( "/admin/game-data/maps/:mapNum/tiles/:x/:y/:layer", async (request, response) => { try { - const authorized = await requireAdminEmailSession( - request, - response, - ); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); const x = Number.parseInt(request.params.x ?? "", 10); const y = Number.parseInt(request.params.y ?? "", 10); @@ -900,6 +945,18 @@ app.delete( return; } + const overrideProtected = Boolean( + request.query?.overrideProtected === "true" || + request.body?.overrideProtected, + ); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json({ removed: await clearTile(mapNum, x, y, layer) }); } catch (error) { const message = @@ -953,9 +1010,6 @@ app.get("/maps/:mapNum/overrides", async (request, response) => { /** Publica los borradores de un mapa. A partir de aca los ven los jugadores. */ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -963,8 +1017,17 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json( - await publishMap(mapNum, authorized.session.account._id), + await publishMap(mapNum, authorized.accountId), ); } catch (error) { const message = @@ -976,9 +1039,6 @@ app.post("/admin/game-data/maps/:mapNum/publish", async (request, response) => { /** Descarta los borradores sin tocar lo ya publicado. */ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -986,6 +1046,15 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json(await discardDrafts(mapNum)); } catch (error) { const message = @@ -1000,9 +1069,6 @@ app.post("/admin/game-data/maps/:mapNum/discard", async (request, response) => { */ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); - if (!authorized) return; - const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -1010,6 +1076,15 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { return; } + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); + if (!authorized) return; + response.json(await revertMap(mapNum)); } catch (error) { const message = @@ -1018,6 +1093,95 @@ app.post("/admin/game-data/maps/:mapNum/revert", async (request, response) => { } }); +/** Concede permisos de edicion de mapa a una cuenta (superadmin). */ +app.post( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + await grantMapPermission( + accountId, + mapNum, + authorized.session.account._id, + ); + response.json({ ok: true, accountId, mapNum }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Revoca permisos de edicion de mapa a una cuenta (superadmin). */ +app.delete( + "/admin/game-data/maps/:mapNum/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + const accountId = request.params.accountId ?? ""; + + if (!Number.isInteger(mapNum) || mapNum < 0 || !accountId) { + response.status(400).json({ error: "Parametros invalidos." }); + return; + } + + const revoked = await revokeMapPermission(accountId, mapNum); + response.json({ ok: true, revoked }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** Lista los mapas asignados a una cuenta. */ +app.get( + "/admin/game-data/maps/permissions/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const accountId = request.params.accountId ?? ""; + if (!accountId) { + response.status(400).json({ error: "accountId requerido." }); + return; + } + + const maps = await listAccountMapPermissions(accountId); + response.json({ accountId, maps }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + /** Cuantos tiles hay en borrador y cuantos publicados. */ app.get("/admin/game-data/maps/:mapNum/status", async (request, response) => { try { diff --git a/api/src/tests/worldBuilder_permissions.integration.test.ts b/api/src/tests/worldBuilder_permissions.integration.test.ts new file mode 100644 index 00000000..89822590 --- /dev/null +++ b/api/src/tests/worldBuilder_permissions.integration.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { + checkMapEditPermission, + grantMapPermission, + revokeMapPermission, + listAccountMapPermissions, + isProtectedMap, + PROTECTED_MAPS, +} from "../repositories/worldBuilder"; + +describe("World Builder Permissions and Protected Maps", () => { + const superAdminAccountId = "00000000-0000-0000-0000-000000000001"; + const collaboratorAccountId = "00000000-0000-0000-0000-000000000002"; + const unauthorizedAccountId = "00000000-0000-0000-0000-000000000003"; + + it("identifies protected capital/city maps", () => { + assert.equal(isProtectedMap(1), true, "Map 1 (Ullathorpe) must be protected"); + assert.equal(isProtectedMap(34), true, "Map 34 (Nix) must be protected"); + assert.equal(isProtectedMap(59), true, "Map 59 (Banderbill) must be protected"); + assert.equal(isProtectedMap(150), true, "Map 150 (Lindos) must be protected"); + assert.equal(isProtectedMap(50), false, "Map 50 must not be protected"); + }); + + it("rejects unauthorized accounts without permissions with 403", async () => { + const result = await checkMapEditPermission({ + accountId: unauthorizedAccountId, + isSuperAdmin: false, + mapNum: 50, + }); + + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /no tiene permisos/i); + }); + + it("rejects collaborators from modifying protected maps", async () => { + const result = await checkMapEditPermission({ + accountId: collaboratorAccountId, + isSuperAdmin: false, + mapNum: 1, + }); + + assert.equal(result.allowed, false); + assert.match(result.reason ?? "", /protegido/i); + }); + + it("rejects superadmins from modifying protected maps without explicit override", async () => { + const resultWithoutOverride = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 1, + overrideProtected: false, + }); + + assert.equal(resultWithoutOverride.allowed, false); + assert.match(resultWithoutOverride.reason ?? "", /overrideProtected/i); + + const resultWithOverride = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 1, + overrideProtected: true, + }); + + assert.equal(resultWithOverride.allowed, true); + }); + + it("allows superadmins to modify non-protected maps directly", async () => { + const result = await checkMapEditPermission({ + accountId: superAdminAccountId, + isSuperAdmin: true, + mapNum: 75, + }); + + assert.equal(result.allowed, true); + }); +}); \ No newline at end of file