Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 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
13 changes: 13 additions & 0 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +633 to +642

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: Redundant index duplicates game_map_permissions primary key

idx_game_map_permissions_account_map indexes (account_id, map_num), which is identical (same columns, same order) to the PRIMARY KEY (account_id, map_num) that Postgres already backs with a unique btree index. The extra index adds write overhead and storage without benefiting any query. Drop the explicit index.

Remove the duplicate index; the primary key already covers these lookups.:

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)
);
-- (removed redundant idx_game_map_permissions_account_map)
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

98 changes: 98 additions & 0 deletions api/src/repositories/worldBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> = 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<MapPermissionCheckResult> {
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<void> {
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<boolean> {
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<number[]> {
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);
}
Loading