From fae1e75f6f35c88438442bb186f5d1916e06f8b5 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Sat, 11 Apr 2026 00:31:49 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20enforce=20character=20ownership=20?= =?UTF-8?q?=E2=80=94=20players=20own=20their=20characters,=20DMs=20can=20m?= =?UTF-8?q?odify=20any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- server/src/routes/characters.ts | 45 +++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/server/src/routes/characters.ts b/server/src/routes/characters.ts index 19d40b6..fa35832 100644 --- a/server/src/routes/characters.ts +++ b/server/src/routes/characters.ts @@ -5,6 +5,7 @@ import type { Server } from "socket.io"; import db from "../db.js"; import { broadcastToCampaign } from "../socket.js"; import { parseJson } from "../utils/parseJson.js"; +import { requireAuth } from "../auth/middleware.js"; type CampaignParams = ParamsDictionary & { campaignId: string }; @@ -29,6 +30,24 @@ function parseTalents(rows: RowDataPacket[]) { return rows.map((r) => ({ ...r, effect: parseJson(r.effect) })); } +async function canModifyCharacter(characterId: string, userId: number): Promise { + // DMs can modify any character in their campaign + const [dmCheck] = await db.execute( + `SELECT cm.role FROM campaign_members cm + JOIN characters c ON c.campaign_id = cm.campaign_id + WHERE c.id = ? AND cm.user_id = ? AND cm.role = 'dm'`, + [characterId, userId] + ); + if (dmCheck.length > 0) return true; + + // Players can only modify their own characters + const [ownerCheck] = await db.execute( + "SELECT id FROM characters WHERE id = ? AND user_id = ?", + [characterId, userId] + ); + return ownerCheck.length > 0; +} + async function enrichCharacters(characters: RowDataPacket[]) { return Promise.all( characters.map(async (char) => { @@ -72,7 +91,7 @@ router.get("/", async (req, res) => { }); // POST /api/campaigns/:campaignId/characters -router.post("/", async (req, res) => { +router.post("/", requireAuth, async (req, res) => { try { const { campaignId } = req.params; const { name, class: charClass, ancestry, hp_max } = req.body; @@ -82,12 +101,15 @@ router.post("/", async (req, res) => { return; } + const userId = req.user?.userId ?? null; + const [result] = await db.execute( `INSERT INTO characters - (campaign_id, name, class, ancestry, hp_current, hp_max, color) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + (campaign_id, user_id, name, class, ancestry, hp_current, hp_max, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ campaignId, + userId, name.trim(), charClass ?? "Fighter", ancestry ?? "Human", @@ -129,9 +151,16 @@ router.post("/", async (req, res) => { }); // PATCH /api/characters/:id -router.patch("/:id", async (req, res) => { +router.patch("/:id", requireAuth, async (req, res) => { try { const { id } = req.params; + + const allowed = await canModifyCharacter(String(id), req.user!.userId); + if (!allowed) { + res.status(403).json({ error: "Not authorized to modify this character" }); + return; + } + const allowedFields = [ "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", "ac", "alignment", "title", "notes", "background", "deity", "languages", @@ -182,7 +211,7 @@ router.patch("/:id", async (req, res) => { }); // DELETE /api/characters/:id -router.delete("/:id", async (req, res) => { +router.delete("/:id", requireAuth, async (req, res) => { try { const [rows] = await db.execute( "SELECT * FROM characters WHERE id = ?", @@ -193,6 +222,12 @@ router.delete("/:id", async (req, res) => { return; } + const allowed = await canModifyCharacter(String(req.params.id), req.user!.userId); + if (!allowed) { + res.status(403).json({ error: "Not authorized to delete this character" }); + return; + } + await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]); const io: Server = req.app.get("io");