diff --git a/UndauntedMetagame/src/controllers/inventory.ts b/UndauntedMetagame/src/controllers/inventory.ts index d5cd128..7e22589 100644 --- a/UndauntedMetagame/src/controllers/inventory.ts +++ b/UndauntedMetagame/src/controllers/inventory.ts @@ -4,7 +4,7 @@ import { inventory } from "../db/schema"; import { logger } from "../logger"; import { DoesCharacterBelongToUserId } from "./character"; -export type InventoryError = "forbidden" | "not_found" | "conflict" | "invalid_inventory_data" | "db_error"; +export type InventoryError = "forbidden" | "not_found" | "conflict" | "invalid_inventory_item" | "invalid_inventory_data" | "db_error"; export type InventoryResult = { success: true, data?: T } | { success: false, error: InventoryError }; class InventoryConflictError extends Error { @@ -14,6 +14,13 @@ class InventoryConflictError extends Error { } } +class InventoryValidationError extends Error { + constructor(message: string){ + super(message); + this.name = "InventoryValidationError"; + } +} + function MakeEmptyInventoryRow(CharacterId: string): typeof inventory.$inferInsert { return { characterId: CharacterId, @@ -26,17 +33,45 @@ function FindInstancedItemIndex(InstancedItems: any[], InstanceId: string){ return InstancedItems.findIndex((Item) => Item.instanceId === InstanceId); } -function AssertSuperiorInstancedItemVersion(CurrentItem: any, IncomingItem: any, Operation: string){ +function HasStatelessItemData(Item: any){ + return !Object.prototype.hasOwnProperty.call(Item, "itemData") || Item.itemData == null; +} + +function IsAllowedStaleStatelessReplacement(CurrentItem: any, IncomingItem: any, Operation: string){ + return Operation !== "remove" + && HasStatelessItemData(CurrentItem) + && HasStatelessItemData(IncomingItem) + && CurrentItem.updateVersion === 0; +} + +function AssertValidIncomingInstancedItem(IncomingItem: any, Operation: string){ + if(HasStatelessItemData(IncomingItem) && IncomingItem.updateVersion !== 0){ + throw new InventoryValidationError(`Refusing stateless instanced item ${Operation} ${IncomingItem.catalogId}/${IncomingItem.instanceId}: expected updateVersion 0, got ${IncomingItem.updateVersion}`); + } +} + +function AssertExistingInstancedItemWrite(CurrentItem: any, IncomingItem: any, Operation: string): "write" | "skip"{ + if(Operation !== "remove"){ + AssertValidIncomingInstancedItem(IncomingItem, Operation); + } + if(typeof CurrentItem.updateVersion !== "number"){ - return; + return "write"; + } + + if(IsAllowedStaleStatelessReplacement(CurrentItem, IncomingItem, Operation)){ + return "skip"; } - if(typeof IncomingItem.updateVersion !== "number" || IncomingItem.updateVersion <= CurrentItem.updateVersion){ + const IsStaleInstancedItem = typeof IncomingItem.updateVersion !== "number" || IncomingItem.updateVersion <= CurrentItem.updateVersion; + if(IsStaleInstancedItem){ throw new InventoryConflictError(`Refusing stale instanced item ${Operation} ${IncomingItem.catalogId}/${IncomingItem.instanceId}: current updateVersion ${CurrentItem.updateVersion}, incoming updateVersion ${IncomingItem.updateVersion}`); } + + return "write"; } -export async function UpdateInstancedItem(CharacterId: string, UserId: string, InstanceId: string, CatalogId: string, ItemData: string, UpdateVersion: number): Promise>{ +export async function UpdateInstancedItem(CharacterId: string, UserId: string, InstanceId: string, CatalogId: string, ItemData: string | null | undefined, UpdateVersion: number): Promise>{ if(!await DoesCharacterBelongToUserId(UserId, CharacterId)){ logger.error(`Specified characterId ${CharacterId} does not belong to user ${UserId}`); return {success: false, error: "forbidden"}; @@ -55,25 +90,30 @@ export async function UpdateInstancedItem(CharacterId: string, UserId: string, I } const InstancedItems: any[] = JSON.parse(CurrentInventory.instancedItems); + const ItemIndex = InstancedItems.findIndex((Item) => Item.catalogId === CatalogId && Item.instanceId === InstanceId); - for(const Item of InstancedItems){ - if(Item.catalogId === CatalogId && Item.instanceId == InstanceId){ - AssertSuperiorInstancedItemVersion(Item, {catalogId: CatalogId, instanceId: InstanceId, updateVersion: UpdateVersion}, "update"); + if(ItemIndex < 0){ + return {success: false, error: "not_found"} as InventoryResult; + } - Item.itemData = ItemData; - Item.updateVersion = UpdateVersion; + const Item = InstancedItems[ItemIndex]; + const IncomingItem = {catalogId: CatalogId, instanceId: InstanceId, itemData: ItemData, updateVersion: UpdateVersion}; - logger.info(`Updating Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`); + if(AssertExistingInstancedItemWrite(Item, IncomingItem, "update") === "skip"){ + logger.info(`Skipping stale stateless Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`); + return {success: true, data: Item} as InventoryResult; + } - tx.update(inventory).set({ - instancedItems: JSON.stringify(InstancedItems) - }).where(eq(inventory.characterId, CharacterId)).run(); + Item.itemData = ItemData; + Item.updateVersion = UpdateVersion; - return {success: true, data: Item} as InventoryResult; - } - } + logger.info(`Updating Instanced Item ${CatalogId} for CharacterId ${CharacterId} and UserId ${UserId}`); + + tx.update(inventory).set({ + instancedItems: JSON.stringify(InstancedItems) + }).where(eq(inventory.characterId, CharacterId)).run(); - return {success: false, error: "not_found"} as InventoryResult; + return {success: true, data: Item} as InventoryResult; }); } catch(error){ @@ -82,6 +122,11 @@ export async function UpdateInstancedItem(CharacterId: string, UserId: string, I return {success: false, error: "conflict"}; } + if(error instanceof InventoryValidationError){ + logger.warn(error.message); + return {success: false, error: "invalid_inventory_item"}; + } + if(error instanceof SyntaxError){ logger.error(error, `Invalid inventory data while updating instanced item ${CatalogId} for characterId ${CharacterId} and userId ${UserId}`); return {success: false, error: "invalid_inventory_data"}; @@ -127,13 +172,15 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin if(ShouldTouchInstancedItems){ const InstancedItems: any[] = JSON.parse(CurrentInventory.instancedItems); + let DidUpdateInstancedItems = false; for(const ItemToRemove of InstancedItemsToRemove){ const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToRemove.instanceId); if(ItemIndex >= 0){ - AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToRemove, "remove"); + AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToRemove, "remove"); InstancedItems.splice(ItemIndex, 1); + DidUpdateInstancedItems = true; } } @@ -141,11 +188,17 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToSave.instanceId); if(ItemIndex >= 0){ - AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToSave, "save"); + if(AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToSave, "save") === "skip"){ + continue; + } + InstancedItems[ItemIndex] = ItemToSave; + DidUpdateInstancedItems = true; } else{ + AssertValidIncomingInstancedItem(ItemToSave, "save"); InstancedItems.push(ItemToSave); + DidUpdateInstancedItems = true; } } @@ -153,15 +206,23 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin const ItemIndex = FindInstancedItemIndex(InstancedItems, ItemToAdd.instanceId); if(ItemIndex >= 0){ - AssertSuperiorInstancedItemVersion(InstancedItems[ItemIndex], ItemToAdd, "add"); + if(AssertExistingInstancedItemWrite(InstancedItems[ItemIndex], ItemToAdd, "add") === "skip"){ + continue; + } + InstancedItems[ItemIndex] = ItemToAdd; + DidUpdateInstancedItems = true; } else{ + AssertValidIncomingInstancedItem(ItemToAdd, "add"); InstancedItems.push(ItemToAdd); + DidUpdateInstancedItems = true; } } - Update.instancedItems = JSON.stringify(InstancedItems); + if(DidUpdateInstancedItems){ + Update.instancedItems = JSON.stringify(InstancedItems); + } } if(ShouldTouchStackedItems){ @@ -195,7 +256,9 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin Update.stackedItems = JSON.stringify(StackedItems); } - tx.update(inventory).set(Update).where(eq(inventory.characterId, CharacterId)).run(); + if(Object.keys(Update).length > 0){ + tx.update(inventory).set(Update).where(eq(inventory.characterId, CharacterId)).run(); + } }); } catch(error){ @@ -204,6 +267,11 @@ export async function RunInventoryTransaction(UserId: string, CharacterId: strin return {success: false, error: "conflict"}; } + if(error instanceof InventoryValidationError){ + logger.warn(error.message); + return {success: false, error: "invalid_inventory_item"}; + } + if(error instanceof SyntaxError){ logger.error(error, `Invalid inventory data while running transaction ${TransactionId} for characterId ${CharacterId} and userId ${UserId}`); return {success: false, error: "invalid_inventory_data"}; diff --git a/UndauntedMetagame/src/db/schema.ts b/UndauntedMetagame/src/db/schema.ts index 7acfa38..21a957d 100644 --- a/UndauntedMetagame/src/db/schema.ts +++ b/UndauntedMetagame/src/db/schema.ts @@ -1,11 +1,10 @@ -import { boolean } from "drizzle-orm/gel-core"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const users = sqliteTable("users", { userId: text("userId").notNull().primaryKey(), name: text("name").notNull(), notes: integer("notes").notNull(), - isAdmin: boolean("isAdmin").notNull().default(0) + isAdmin: integer("isAdmin", {mode: "boolean"}).notNull().default(false) }) export const characters = sqliteTable("characters", { @@ -66,5 +65,5 @@ export const encounteredcontent = sqliteTable("encounteredcontent", { export const invitecodes = sqliteTable("invitecodes", { inviteCode: text("invitecode").notNull().primaryKey(), usesRemaining: integer("usesRemaining").notNull(), - infiniteUses: boolean("infiniteUses").notNull() -}); \ No newline at end of file + infiniteUses: integer("infiniteUses", {mode: "boolean"}).notNull() +}); diff --git a/UndauntedMetagame/src/routes/inventory.ts b/UndauntedMetagame/src/routes/inventory.ts index 7b0c29d..b3d0f62 100644 --- a/UndauntedMetagame/src/routes/inventory.ts +++ b/UndauntedMetagame/src/routes/inventory.ts @@ -13,6 +13,8 @@ function StatusForInventoryError(Error: InventoryError){ return 404; case "conflict": return 409; + case "invalid_inventory_item": + return 400; case "invalid_inventory_data": case "db_error": return 500; diff --git a/UndauntedMetagame/src/routes/undauntedapi.ts b/UndauntedMetagame/src/routes/undauntedapi.ts index 1e437b7..a7711b1 100644 --- a/UndauntedMetagame/src/routes/undauntedapi.ts +++ b/UndauntedMetagame/src/routes/undauntedapi.ts @@ -33,7 +33,7 @@ undauntedApiRouter.get("/InviteCodes", HasUndauntedAdminApiKey, async (req, res) undauntedApiRouter.post("/RegisterInviteCode", HasUndauntedAdminApiKey, async (req, res) => { const NewInviteCode = req.body.NewInviteCode; const Uses = req.body.Uses; - const InfiniteUses = req.body.InfiniteUses ? 1 : 0; + const InfiniteUses = !!req.body.InfiniteUses; await RegisterInviteCode(NewInviteCode, Uses, InfiniteUses); @@ -109,4 +109,4 @@ undauntedApiRouter.get("/PublicOnlineStats", HasUndauntedUserApiKey, async (req, res.json({ NumActivePlayers: PlayerData.length }); -}); \ No newline at end of file +});