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
15 changes: 15 additions & 0 deletions api/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -627,3 +627,18 @@ 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);

CREATE TABLE IF NOT EXISTS game_map_objects (
id SERIAL PRIMARY KEY,
map_id INTEGER NOT NULL CHECK (map_id > 0),
x INTEGER NOT NULL CHECK (x BETWEEN 1 AND 100),
y INTEGER NOT NULL CHECK (y BETWEEN 1 AND 100),
obj_index INTEGER NOT NULL CHECK (obj_index > 0),
amount INTEGER NOT NULL DEFAULT 1 CHECK (amount > 0),
state VARCHAR(50) NOT NULL DEFAULT 'default' CHECK (state IN ('default', 'door_open', 'door_closed', 'locked', 'destroyed')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 Bug: state CHECK constraint disagrees with allowed app states

The DB CHECK on game_map_objects.state permits ('default','door_open','door_closed','locked','destroyed'), but the application's ALLOWED_MAP_OBJECT_STATES / MapObjectState are ('placed','structure','door_open','door_closed','sign'). validateState() will accept 'placed', 'structure', and 'sign' (including the defaults used by placeObject → 'placed' and placeStructure → 'structure'), so the INSERT will violate the CHECK constraint and throw at the DB layer — placeObject and placeStructure fail for every default call. Align the two lists: update the schema CHECK to IN ('placed','structure','door_open','door_closed','sign') and set the column DEFAULT to 'placed' (or 'structure').

Match the schema CHECK/DEFAULT to the application's MapObjectState values.:

state VARCHAR(50) NOT NULL DEFAULT 'placed' CHECK (state IN ('placed', 'structure', 'door_open', 'door_closed', 'sign')),
  • Apply fix

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

created_by UUID REFERENCES accounts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_game_map_objects_map_coords
ON game_map_objects(map_id, x, y);
161 changes: 161 additions & 0 deletions api/src/repositories/mapObjects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import pool from "../db";

export const MAP_SIZE = 100;

export type MapObjectState = "placed" | "structure" | "door_open" | "door_closed" | "sign";

export type MapObjectRecord = {
id: number;
mapId: number;
x: number;
y: number;
objIndex: number;
amount: number;
state: MapObjectState;
createdBy: string | null;
createdAt: string;
};

export type StructureTileInput = {
x: number;
y: number;
objIndex: number;
amount?: number;
state?: MapObjectState;
};

export const ALLOWED_MAP_OBJECT_STATES: MapObjectState[] = [
"placed",
"structure",
"door_open",
"door_closed",
"sign",
];

export function validateState(state: unknown): asserts state is MapObjectState {
if (typeof state !== "string" || !ALLOWED_MAP_OBJECT_STATES.includes(state as MapObjectState)) {
throw new Error(`Estado no valido. Permitidos: ${ALLOWED_MAP_OBJECT_STATES.join(", ")}`);
}
}

function validateCoordinates(x: unknown, y: unknown): void {
if (!Number.isInteger(x) || !Number.isInteger(y) || (x as number) < 1 || (x as number) > MAP_SIZE || (y as number) < 1 || (y as number) > MAP_SIZE) {
throw new Error(`Coordenadas fuera de rango (1-${MAP_SIZE}): (${x}, ${y})`);
}
}

export async function placeObject(
mapId: number,
x: number,
y: number,
objIndex: number,
amount: number = 1,
createdBy: string | null = null,
state: MapObjectState = "placed"
): Promise<MapObjectRecord> {
if (!Number.isInteger(mapId) || mapId < 1) {
throw new Error("mapId debe ser un entero positivo");
}
validateCoordinates(x, y);
if (!Number.isInteger(objIndex) || objIndex <= 0) {
throw new Error("objIndex debe ser mayor a 0");
}
if (!Number.isInteger(amount) || amount <= 0) {
throw new Error("amount debe ser mayor a 0");
}
validateState(state);

const res = await pool.query(
`INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`,
[mapId, x, y, objIndex, amount, state, createdBy]
);
Comment on lines +54 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: state is not validated against allowed MapObjectState values

placeObject, setObjectState, and placeStructure write the client-supplied state directly to the DB. MapObjectState is a compile-time-only TypeScript union, so any arbitrary string from the request body is accepted and persisted, corrupting object state semantics (door_open/door_closed/etc.). Validate state against an explicit runtime allow-list (or add a CHECK constraint / enum on the column) and reject unknown values.

Was this helpful? React with 👍 / 👎

return res.rows[0];
Comment thread
gitar-bot[bot] marked this conversation as resolved.
}

export async function moveObject(id: number, newX: number, newY: number): Promise<MapObjectRecord | null> {
validateCoordinates(newX, newY);
const res = await pool.query(
`UPDATE game_map_objects
SET x = $1, y = $2
WHERE id = $3
RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`,
[newX, newY, id]
);
return res.rows[0] || null;
}

export async function removeObject(id: number): Promise<boolean> {
const res = await pool.query(`DELETE FROM game_map_objects WHERE id = $1`, [id]);
return (res.rowCount ?? 0) > 0;
}

export async function getMapObjects(mapId: number): Promise<MapObjectRecord[]> {
const res = await pool.query(
`SELECT id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"
FROM game_map_objects
WHERE map_id = $1
ORDER BY id ASC`,
[mapId]
);
return res.rows;
}

export async function setObjectState(id: number, state: MapObjectState): Promise<MapObjectRecord | null> {
if (!Number.isInteger(id) || id < 1) {
throw new Error("id debe ser un entero positivo");
}
validateState(state);
const res = await pool.query(
`UPDATE game_map_objects
SET state = $1
WHERE id = $2
RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`,
[state, id]
);
return res.rows[0] || null;
}

export async function placeStructure(
mapId: number,
tiles: StructureTileInput[],
createdBy: string | null = null
): Promise<MapObjectRecord[]> {
if (!Number.isInteger(mapId) || mapId < 1) {
throw new Error("mapId debe ser un entero positivo");
}
if (!Array.isArray(tiles) || !tiles.length) return [];

const client = await pool.connect();
try {
await client.query("BEGIN");
const results: MapObjectRecord[] = [];
for (const tile of tiles) {
validateCoordinates(tile.x, tile.y);
if (!Number.isInteger(tile.objIndex) || tile.objIndex <= 0) {
throw new Error("objIndex debe ser mayor a 0");
}
const tileAmount = tile.amount ?? 1;
if (!Number.isInteger(tileAmount) || tileAmount <= 0) {
throw new Error("amount debe ser mayor a 0");
}
const tileState = tile.state ?? "structure";
validateState(tileState);
const res = await client.query(
`INSERT INTO game_map_objects (map_id, x, y, obj_index, amount, state, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, map_id AS "mapId", x, y, obj_index AS "objIndex", amount, state, created_by AS "createdBy", created_at AS "createdAt"`,
[mapId, tile.x, tile.y, tile.objIndex, tileAmount, tileState, createdBy]
);
results.push(res.rows[0]);
}
await client.query("COMMIT");
return results;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
89 changes: 89 additions & 0 deletions api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2891,4 +2891,93 @@ app.get("/user-online-stats", async (request, response) => {
}
});

app.get("/internal/map-objects/:mapId", requireAuth, async (request, response) => {
try {
const mapId = Number(request.params.mapId);
const { getMapObjects } = await import("./repositories/mapObjects");
const result = await getMapObjects(mapId);
response.json(result);
} catch (error) {
response.status(500).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});

app.post("/internal/map-objects", requireAuth, async (request, response) => {
try {
const { mapId, x, y, objIndex, amount, createdBy, state } = request.body;
const { placeObject } = await import("./repositories/mapObjects");
const result = await placeObject(mapId, x, y, objIndex, amount, createdBy, state);
response.status(201).json(result);
} catch (error) {
response.status(400).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});
Comment on lines +2907 to +2918

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Security: Editor write endpoints use static-token requireAuth, not admin auth

The new /internal/map-objects write endpoints (place/move/delete/state/structure) mutate map/world data but are guarded only by requireAuth, which just compares the Authorization header to a single static config.tokenAuth. Comparable game-data/map-editing mutations in this server use requireAdminEmailSession + isAuthorizedGameDataAdmin. If these endpoints are reachable by regular clients (not only trusted server-to-server callers), anyone holding the shared token can place/move/delete world objects. Confirm the intended caller; if end users can reach these routes, switch to admin session authorization.

Was this helpful? React with 👍 / 👎


app.put("/internal/map-objects/:id/move", requireAuth, async (request, response) => {
try {
const id = Number(request.params.id);
const { newX, newY } = request.body;
const { moveObject } = await import("./repositories/mapObjects");
const result = await moveObject(id, newX, newY);
if (!result) {
return response.status(404).json({ error: "Objeto no encontrado" });
}
response.json(result);
} catch (error) {
response.status(400).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});

app.delete("/internal/map-objects/:id", requireAuth, async (request, response) => {
try {
const id = Number(request.params.id);
const { removeObject } = await import("./repositories/mapObjects");
const ok = await removeObject(id);
if (!ok) {
return response.status(404).json({ error: "Objeto no encontrado" });
}
response.json({ success: true });
} catch (error) {
response.status(500).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});

app.put("/internal/map-objects/:id/state", requireAuth, async (request, response) => {
try {
const id = Number(request.params.id);
const { state } = request.body;
const { setObjectState } = await import("./repositories/mapObjects");
const result = await setObjectState(id, state);
if (!result) {
return response.status(404).json({ error: "Objeto no encontrado" });
}
response.json(result);
} catch (error) {
response.status(400).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});

app.post("/internal/map-objects/structure", requireAuth, async (request, response) => {
try {
const { mapId, tiles, createdBy } = request.body;
const { placeStructure } = await import("./repositories/mapObjects");
const result = await placeStructure(mapId, tiles, createdBy);
response.status(201).json(result);
} catch (error) {
response.status(400).json({
error: error instanceof Error ? error.message : "Unexpected error",
});
}
});

void start();