diff --git a/api/.env.example b/api/.env.example index 1d568703..245ecc09 100644 --- a/api/.env.example +++ b/api/.env.example @@ -2,3 +2,15 @@ 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 + +# Amazon SES v2 Configuration (Password Reset & System Emails) +SES_REGION=us-east-1 +SES_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +SES_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +SES_FROM_EMAIL=soporte@aoweb.app +SES_FROM_NAME=AOWeb diff --git a/api/schema.sql b/api/schema.sql index c5103206..b708bf94 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -627,3 +627,30 @@ 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); + +-- Entradas de paleta dinamicas por mapa (permite asignar graficos subidos a tiles reusables). +CREATE TABLE IF NOT EXISTS game_map_palette_overrides ( + map_num INTEGER NOT NULL CHECK (map_num > 0), + palette_id INTEGER NOT NULL CHECK (palette_id > 0), + graphics INTEGER[] NOT NULL, + blocked BOOLEAN NOT NULL DEFAULT FALSE, + updated_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (map_num, palette_id) +); + +CREATE INDEX IF NOT EXISTS idx_game_map_palette_overrides_map + ON game_map_palette_overrides(map_num); diff --git a/api/src/lib/email.ts b/api/src/lib/email.ts index 7d114dad..e26924a5 100644 --- a/api/src/lib/email.ts +++ b/api/src/lib/email.ts @@ -101,30 +101,45 @@ function buildPasswordResetText({ displayName, resetUrl }: PasswordResetEmailInp } export async function sendPasswordResetEmail(input: PasswordResetEmailInput): Promise { - const client = getSesClient(); + let client: SESv2Client; + try { + client = getSesClient(); + } catch (configError) { + console.error("[Email/SES] Error de configuracion al intentar enviar email de recuperacion:", configError); + throw new Error("No se pudo enviar el email de recuperacion. Intenta de nuevo."); + } - await client.send(new SendEmailCommand({ - FromEmailAddress: `${config.sesFromName} <${config.sesFromEmail}>`, - Destination: { - ToAddresses: [input.to], - }, - Content: { - Simple: { - Subject: { - Data: "AOWeb | Recuperacion de contraseña", - Charset: "UTF-8", - }, - Body: { - Html: { - Data: buildPasswordResetHtml(input), + try { + await client.send(new SendEmailCommand({ + FromEmailAddress: `${config.sesFromName} <${config.sesFromEmail}>`, + Destination: { + ToAddresses: [input.to], + }, + Content: { + Simple: { + Subject: { + Data: "AOWeb | Recuperacion de contraseña", Charset: "UTF-8", }, - Text: { - Data: buildPasswordResetText(input), - Charset: "UTF-8", + Body: { + Html: { + Data: buildPasswordResetHtml(input), + Charset: "UTF-8", + }, + Text: { + Data: buildPasswordResetText(input), + Charset: "UTF-8", + }, }, }, }, - }, - })); + })); + } catch (awsError: any) { + console.error(`[Email/SES] Error de envio AWS SES al destinatario ${input.to}:`, { + name: awsError?.name, + message: awsError?.message, + code: awsError?.code || awsError?.$metadata?.httpStatusCode, + }); + throw new Error("No se pudo enviar el email de recuperacion. Intenta de nuevo."); + } } diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 8fd58c95..a67359d6 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -417,3 +417,265 @@ 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); +} + +export type GraphicMetadata = { + grhIndex: number; + width: number; + height: number; + frameCount: number; + fileNum: number; + offX: number; + offY: number; + isUploaded: boolean; + url: string; +}; + +export async function getGraphicMetadata( + grhIndex: number, +): Promise { + if (grhIndex >= UPLOADED_GRAPHIC_INDEX_START) { + const result = await pool.query<{ + grh_index: number; + width: number; + height: number; + }>( + `SELECT grh_index, width, height FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grhIndex], + ); + const row = result.rows[0]; + if (!row) return null; + + return { + grhIndex: row.grh_index, + width: row.width, + height: row.height, + frameCount: 1, + fileNum: row.grh_index, + offX: 0, + offY: 0, + isUploaded: true, + url: `/admin/game-data/graphics/${row.grh_index}`, + }; + } + + // Gráfico original del juego + return { + grhIndex, + width: 32, + height: 32, + frameCount: 1, + fileNum: grhIndex, + offX: 0, + offY: 0, + isUploaded: false, + url: `/graphics/${grhIndex}.png`, + }; +} + +export const paletteEntrySchema = z.object({ + paletteId: z.number().int().positive().optional(), + graphics: z.array(z.number().int().positive().nullable()).min(1).max(4), + blocked: z.boolean().default(false), +}); + +export type PaletteEntryInput = z.infer; + +export type PaletteEntry = { + paletteId: number; + graphics: (number | null)[]; + blocked: boolean; + updatedAt: string; +}; + +/** + * Agrega o actualiza una entrada en la paleta de un mapa. + * Valida que todos los gráficos referenciados existan en la base o catálogo base. + */ +export async function upsertPaletteEntry( + mapNum: number, + entry: PaletteEntryInput, + accountId: string, +): Promise { + // Validar existencia de cada grafico referenciado + for (let i = 0; i < entry.graphics.length; i++) { + const grh = entry.graphics[i]; + if (grh != null) { + if (grh >= UPLOADED_GRAPHIC_INDEX_START) { + const exists = await pool.query( + `SELECT 1 FROM game_uploaded_graphics WHERE grh_index = $1 LIMIT 1`, + [grh], + ); + if (exists.rowCount === 0) { + throw new Error( + `El grafico ${grh} no existe en la base de assets. Subilo antes de asignarlo a la paleta.`, + ); + } + } else if (grh <= 0 || grh > 320151) { + throw new Error( + `El indice de grafico ${grh} esta fuera de rango (1..320151).`, + ); + } + } + } + + let paletteId = entry.paletteId; + + if (!paletteId) { + // Asignar siguiente ID de paleta para el mapa (por encima de las paletas estándar base) + const nextIdResult = await pool.query<{ next_id: number }>( + `SELECT COALESCE(MAX(palette_id), 1000) + 1 AS next_id + FROM game_map_palette_overrides + WHERE map_num = $1`, + [mapNum], + ); + paletteId = Number(nextIdResult.rows[0]?.next_id ?? 1001); + } + + const graphicsArray = entry.graphics.map((g) => (g == null ? 0 : g)); + + const result = await pool.query<{ + palette_id: number; + graphics: number[]; + blocked: boolean; + updated_at: Date; + }>( + `INSERT INTO game_map_palette_overrides + (map_num, palette_id, graphics, blocked, updated_by_account_id, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (map_num, palette_id) DO UPDATE + SET graphics = EXCLUDED.graphics, + blocked = EXCLUDED.blocked, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW() + RETURNING palette_id, graphics, blocked, updated_at`, + [mapNum, paletteId, graphicsArray, entry.blocked ?? false, accountId], + ); + + const row = result.rows[0]; + return { + paletteId: row.palette_id, + graphics: row.graphics.map((g) => (g === 0 ? null : g)), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + }; +} + +export async function listMapPalette(mapNum: number): Promise { + const result = await pool.query<{ + palette_id: number; + graphics: number[]; + blocked: boolean; + updated_at: Date; + }>( + `SELECT palette_id, graphics, blocked, updated_at + FROM game_map_palette_overrides + WHERE map_num = $1 + ORDER BY palette_id ASC`, + [mapNum], + ); + + return result.rows.map((row) => ({ + paletteId: row.palette_id, + graphics: row.graphics.map((g) => (g === 0 ? null : g)), + blocked: row.blocked, + updatedAt: row.updated_at.toISOString(), + })); +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..450c10f7 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -96,17 +96,26 @@ import { upsertGameBalance, } from "./repositories/gameBalance"; import { + checkMapEditPermission, clearTile, discardDrafts, getGraphicContent, + getGraphicMetadata, getMapStatus, + grantMapPermission, + isProtectedMap, + listAccountMapPermissions, listGraphics, listMapOverrides, + listMapPalette, paintTiles, paintTilesSchema, + paletteEntrySchema, publishMap, revertMap, + revokeMapPermission, uploadGraphic, + upsertPaletteEntry, } from "./repositories/worldBuilder"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { @@ -233,6 +242,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"); @@ -845,11 +894,73 @@ app.get("/game-data/graphics/:grhIndex.png", async (request, response) => { } }); -app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { +/** Devuelve la metadata de resolución de un gráfico para el motor cliente. */ +app.get("/graphics/:grhIndex/metadata", async (request, response) => { try { - const authorized = await requireAdminEmailSession(request, response); + const grhIndex = Number.parseInt(request.params.grhIndex ?? "", 10); + + if (!Number.isInteger(grhIndex) || grhIndex <= 0) { + response.status(400).json({ error: "Indice invalido." }); + return; + } + + const metadata = await getGraphicMetadata(grhIndex); + if (!metadata) { + response.status(404).json({ error: "Grafico no encontrado." }); + return; + } + + response.json(metadata); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Agrega o actualiza una entrada en la paleta del mapa. */ +app.put("/admin/game-data/maps/:mapNum/palette", async (request, response) => { + try { + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + return; + } + + const overrideProtected = Boolean(request.body?.overrideProtected); + const authorized = await requireMapEditSession( + request, + response, + mapNum, + overrideProtected, + ); if (!authorized) return; + const parsed = paletteEntrySchema.safeParse(request.body); + if (!parsed.success) { + response + .status(400) + .json({ error: JSON.stringify(parsed.error.issues) }); + return; + } + + const result = await upsertPaletteEntry( + mapNum, + parsed.data, + authorized.accountId, + ); + response.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Lista las entradas de paleta dinámicas registradas para un mapa. */ +app.get("/admin/game-data/maps/:mapNum/palette", async (request, response) => { + try { const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); if (!Number.isInteger(mapNum) || mapNum <= 0) { @@ -857,6 +968,33 @@ app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { return; } + const palette = await listMapPalette(mapNum); + response.json({ mapNum, palette }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +app.put("/admin/game-data/maps/:mapNum/tiles", async (request, response) => { + try { + const mapNum = Number.parseInt(request.params.mapNum ?? "", 10); + + if (!Number.isInteger(mapNum) || mapNum <= 0) { + response.status(400).json({ error: "Numero de mapa invalido." }); + 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 +1008,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 +1022,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 +1032,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 +1097,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 +1104,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 +1126,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 +1133,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 +1156,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 +1163,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 +1180,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_palette.integration.test.ts b/api/src/tests/worldBuilder_palette.integration.test.ts new file mode 100644 index 00000000..51f58617 --- /dev/null +++ b/api/src/tests/worldBuilder_palette.integration.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; +import { + getGraphicMetadata, + upsertPaletteEntry, + UPLOADED_GRAPHIC_INDEX_START, +} from "../repositories/worldBuilder"; + +describe("World Builder Palette & Graphic Resolution Engine", () => { + it("resolves original engine graphics metadata seamlessly", async () => { + const metadata = await getGraphicMetadata(5500); + assert.ok(metadata, "Metadata should be resolved"); + assert.equal(metadata.grhIndex, 5500); + assert.equal(metadata.isUploaded, false); + assert.equal(metadata.width, 32); + assert.equal(metadata.height, 32); + assert.equal(metadata.frameCount, 1); + assert.equal(metadata.url, "/graphics/5500.png"); + }); + + it("identifies uploaded graphics starting index above 1,000,000 to prevent collisions", () => { + assert.equal(UPLOADED_GRAPHIC_INDEX_START, 1000000); + assert.ok(UPLOADED_GRAPHIC_INDEX_START > 320151, "Must never collide with max original grh 320151"); + }); + + it("rejects palette entries referencing out-of-range non-existent graphics", async () => { + await assert.rejects( + async () => { + await upsertPaletteEntry( + 1, + { + graphics: [400000], // in dead zone between 320151 and 1000000 + blocked: true, + }, + "00000000-0000-0000-0000-000000000001", + ); + }, + /fuera de rango/i, + ); + }); + + it("rejects palette entries referencing non-existent uploaded assets", async () => { + await assert.rejects( + async () => { + await upsertPaletteEntry( + 1, + { + graphics: [1999999], // non-existent uploaded ID + blocked: false, + }, + "00000000-0000-0000-0000-000000000001", + ); + }, + /no existe en la base de assets/i, + ); + }); +}); \ No newline at end of file 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 diff --git a/server/src/server.ts b/server/src/server.ts index 69dc45af..8fe7de7e 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -700,7 +700,7 @@ function processIdleCharactersTick(now: number) { return; } - const penalizedClientIds = getDuplicateIpIdlePenalizedClientIds(); + const penalizedClientIds = getDuplicateAccountIdlePenalizedClientIds(); for (const idUser in vars.clients) { const client = vars.clients[idUser] as RuntimeClient | undefined; @@ -725,9 +725,9 @@ function processIdleCharactersTick(now: number) { continue; } - const isDuplicateIpScout = penalizedClientIds.has(idUser); - const effectiveIdleTimeoutMs = isDuplicateIpScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; - const idleReferenceAt = isDuplicateIpScout + const isDuplicateAccountScout = penalizedClientIds.has(idUser); + const effectiveIdleTimeoutMs = isDuplicateAccountScout ? DUPLICATE_IP_IDLE_TIMEOUT_MS : idleCharacterTimeoutMs; + const idleReferenceAt = isDuplicateAccountScout ? getScoutIdleReferenceAt(client, user) : Number(client.lastActivityAt ?? now); @@ -758,9 +758,9 @@ function getScoutIdleReferenceAt(client: RuntimeClient, user: ServerCharacter): return Number(client.connectedAt ?? Date.now()); } -function getDuplicateIpIdlePenalizedClientIds(): Set { +function getDuplicateAccountIdlePenalizedClientIds(): Set { const penalizedClientIds = new Set(); - const clientsByIp = new Map< + const clientsByAccount = new Map< string, { idUser: string; @@ -777,34 +777,37 @@ function getDuplicateIpIdlePenalizedClientIds(): Set { continue; } - const clientIp = socket.getIp(client); + // Agrupar por cuenta de usuario en lugar de IP para evitar que jugadores + // de redes móviles (CGNAT) se desconecten entre sí compartiendo IP pública. + const accountKey = + (user as any).idAccount || + (user as any).account_id || + (client as any).accountId || + socket.getIp(client) || + idUser; - if (!clientIp) { - continue; - } - - const clientsForIp = clientsByIp.get(clientIp) ?? []; + const clientsForAccount = clientsByAccount.get(accountKey) ?? []; - clientsForIp.push({ + clientsForAccount.push({ idUser, connectedAt: Number(client.connectedAt ?? 0), miningActive: Boolean(user.harvesting?.active && user.harvesting?.skill === "mining"), }); - clientsByIp.set(clientIp, clientsForIp); + clientsByAccount.set(accountKey, clientsForAccount); } - for (const clientsForIp of clientsByIp.values()) { - if (clientsForIp.length < 2) { + for (const clientsForAccount of clientsByAccount.values()) { + if (clientsForAccount.length < 2) { continue; } - const hasActiveMiner = clientsForIp.some((entry) => entry.miningActive); + const hasActiveMiner = clientsForAccount.some((entry) => entry.miningActive); if (!hasActiveMiner) { continue; } - for (const entry of clientsForIp) { + for (const entry of clientsForAccount) { if (!entry.miningActive) { penalizedClientIds.add(entry.idUser); } @@ -949,3 +952,76 @@ createDynamicScheduler( ); void saveOnlineStatsSnapshot(); + +let isShuttingDown = false; + +async function gracefulShutdown(signal: string): Promise { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + console.log(`[Servidor] Recibida señal ${signal}. Iniciando apagado ordenado...`); + + // Timeout de seguridad: si la API no responde, no bloquear el apagado + const forceExitTimeout = setTimeout(() => { + console.error("[Servidor] Timeout de apagado ordenado excedido. Forzando salida."); + process.exit(1); + }, 5000); + forceExitTimeout.unref(); + + vars.serverReady = false; + + // 1. Notificar a los clientes conectados y cerrar sus sockets ordenadamente + try { + for (const idUser in vars.clients) { + const client = vars.clients[idUser] as RuntimeClient | undefined; + if (client && client.readyState === client.OPEN) { + try { + client.close(1000, "Servidor reiniciando. Por favor vuelve a conectar en unos momentos."); + } catch { + // Ignorar errores individuales al cerrar socket + } + } + } + } catch (error) { + console.error("[Servidor] Error al notificar clientes durante apagado:", error); + } + + // 2. Cerrar servidor WebSocket + try { + if (wsServer) { + wsServer.close(); + } + } catch (error) { + console.error("[Servidor] Error al cerrar wsServer:", error); + } + + // 3. Llamar a la API para desmarcar a todos los personajes conectados + try { + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise<{ updated: number }>((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("API timeout")), 3500); + timeoutId.unref(); + }); + + const fetchPromise = funct.fetchUrl("/internal/characters/reset-connected", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: vars.tokenAuth, + }, + }) as Promise<{ updated: number }>; + + const response = await Promise.race([fetchPromise, timeoutPromise]); + console.log(`[Servidor] Personajes marcados como desconectados al apagar: ${response?.updated ?? 0}.`); + } catch (error) { + console.error("[Servidor] No se pudo desmarcar personajes durante el apagado:", error); + } + + console.log("[Servidor] Apagado ordenado completado exitosamente."); + process.exit(0); +} + +process.on("SIGTERM", () => void gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => void gracefulShutdown("SIGINT")); +