From f145e6e131dba6401c43d816759d1cb8de087584 Mon Sep 17 00:00:00 2001 From: erik Date: Sat, 28 Feb 2026 15:51:20 +0000 Subject: [PATCH] feat: fix inventory service SQL injection, add slot population, and live frontend updates - Replace f-string SQL interpolation with parameterized ANY(:ids) queries - Populate slot column from IntValues[231735296] (Decal Slot key) - Add startup migration to add container_id/slot columns to existing DB - Extract createInventorySlot() for reuse by initial load and live deltas - Add updateInventoryLive() handler for WebSocket inventory_delta messages - Add inventory_delta case to browser WebSocket message dispatcher Co-Authored-By: Claude Opus 4.6 --- inventory-service/main.py | 44 ++++-- static/script.js | 305 +++++++++++++++++++++----------------- 2 files changed, 201 insertions(+), 148 deletions(-) diff --git a/inventory-service/main.py b/inventory-service/main.py index 06a4b51b..3ae8b0fc 100644 --- a/inventory-service/main.py +++ b/inventory-service/main.py @@ -358,7 +358,19 @@ async def startup(): # Create tables if they don't exist Base.metadata.create_all(engine) - + + # Migrate: add container_id and slot columns if missing (added for live inventory) + from sqlalchemy import inspect as sa_inspect + inspector = sa_inspect(engine) + existing_columns = {c['name'] for c in inspector.get_columns('items')} + with engine.begin() as conn: + if 'container_id' not in existing_columns: + conn.execute(sa.text("ALTER TABLE items ADD COLUMN container_id BIGINT DEFAULT 0")) + logger.info("Migration: added container_id column to items table") + if 'slot' not in existing_columns: + conn.execute(sa.text("ALTER TABLE items ADD COLUMN slot INTEGER DEFAULT -1")) + logger.info("Migration: added slot column to items table") + # Create performance indexes create_indexes(engine) @@ -1407,6 +1419,7 @@ async def process_inventory(inventory: InventoryItem): # Container/position tracking container_id=item_data.get('ContainerId', 0), + slot=int(item_data.get('IntValues', {}).get('231735296', item_data.get('IntValues', {}).get(231735296, -1))), # Item state bonded=basic['bonded'], @@ -1561,13 +1574,13 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): ) if existing: - id_list = ','.join(str(row['id']) for row in existing) - await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_list})") + db_ids = [row['id'] for row in existing] + for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', + 'item_enhancements', 'item_ratings', 'item_spells'): + await database.execute( + sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + {"ids": db_ids} + ) await database.execute( "DELETE FROM items WHERE character_name = :character_name AND item_id = :item_id", {"character_name": character_name, "item_id": item_game_id} @@ -1597,6 +1610,7 @@ async def upsert_inventory_item(character_name: str, item: Dict[str, Any]): # Container/position tracking container_id=item.get('ContainerId', 0), + slot=int(item.get('IntValues', {}).get('231735296', item.get('IntValues', {}).get(231735296, -1))), # Item state bonded=basic['bonded'], @@ -1736,15 +1750,15 @@ async def delete_inventory_item(character_name: str, item_id: int): ) if existing: - id_list = ','.join(str(row['id']) for row in existing) + db_ids = [row['id'] for row in existing] # Delete from all related tables first - await database.execute(f"DELETE FROM item_raw_data WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_combat_stats WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_requirements WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_enhancements WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_ratings WHERE item_id IN ({id_list})") - await database.execute(f"DELETE FROM item_spells WHERE item_id IN ({id_list})") + for table in ('item_raw_data', 'item_combat_stats', 'item_requirements', + 'item_enhancements', 'item_ratings', 'item_spells'): + await database.execute( + sa.text(f"DELETE FROM {table} WHERE item_id = ANY(:ids)"), + {"ids": db_ids} + ) # Delete from main items table await database.execute( diff --git a/static/script.js b/static/script.js index 54090c70..a669026d 100644 --- a/static/script.js +++ b/static/script.js @@ -893,6 +893,175 @@ function updateStatsTimeRange(content, name, timeRange) { } // Show or create an inventory window for a character +/** + * Create a single inventory slot DOM element from item data. + * Used by both initial inventory load and live delta updates. + */ +function createInventorySlot(item) { + const slot = document.createElement('div'); + slot.className = 'inventory-slot'; + slot.setAttribute('data-item-id', item.Id || item.id || item.item_id || 0); + + // Create layered icon container + const iconContainer = document.createElement('div'); + iconContainer.className = 'item-icon-composite'; + + // Get base icon ID with portal.dat offset + const iconRaw = item.icon || item.Icon || 0; + const baseIconId = (iconRaw + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + + // Check for overlay and underlay from enhanced format or legacy format + let overlayIconId = null; + let underlayIconId = null; + + // Enhanced format (inventory service) - check for proper icon overlay/underlay properties + if (item.icon_overlay_id && item.icon_overlay_id > 0) { + overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + if (item.icon_underlay_id && item.icon_underlay_id > 0) { + underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + + // Fallback: Enhanced format (inventory service) - check spells object for decal info + if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { + if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { + overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { + underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + } else if (item.IntValues) { + // Raw delta format from plugin - IntValues directly on item + if (item.IntValues['218103849'] && item.IntValues['218103849'] > 100) { + overlayIconId = (item.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + if (item.IntValues['218103850'] && item.IntValues['218103850'] > 100) { + underlayIconId = (item.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + } else if (item.item_data) { + // Legacy format - parse item_data + try { + const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; + if (itemData.IntValues) { + if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { + overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { + underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); + } + } + } catch (e) { + console.warn('Failed to parse item data for', item.name || item.Name); + } + } + + // Create underlay (bottom layer) + if (underlayIconId) { + const underlayImg = document.createElement('img'); + underlayImg.className = 'icon-underlay'; + underlayImg.src = `/icons/${underlayIconId}.png`; + underlayImg.alt = 'underlay'; + underlayImg.onerror = function() { this.style.display = 'none'; }; + iconContainer.appendChild(underlayImg); + } + + // Create base icon (middle layer) + const baseImg = document.createElement('img'); + baseImg.className = 'icon-base'; + baseImg.src = `/icons/${baseIconId}.png`; + baseImg.alt = item.name || item.Name || 'Unknown Item'; + baseImg.onerror = function() { this.src = '/icons/06000133.png'; }; + iconContainer.appendChild(baseImg); + + // Create overlay (top layer) + if (overlayIconId) { + const overlayImg = document.createElement('img'); + overlayImg.className = 'icon-overlay'; + overlayImg.src = `/icons/${overlayIconId}.png`; + overlayImg.alt = 'overlay'; + overlayImg.onerror = function() { this.style.display = 'none'; }; + iconContainer.appendChild(overlayImg); + } + + // Create tooltip data (handle both inventory-service format and raw plugin format) + const itemName = item.name || item.Name || 'Unknown Item'; + slot.dataset.name = itemName; + slot.dataset.value = item.value || item.Value || 0; + slot.dataset.burden = item.burden || item.Burden || 0; + + // Store enhanced data for tooltips + if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { + const enhancedData = {}; + const possibleProps = [ + 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', + 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', + 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', + 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', + 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', + 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', + 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', + 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', + 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' + ]; + possibleProps.forEach(prop => { + if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { + enhancedData[prop] = item[prop]; + } + }); + slot.dataset.enhancedData = JSON.stringify(enhancedData); + } else { + slot.dataset.enhancedData = JSON.stringify({}); + } + + // Add tooltip on hover + slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); + slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); + slot.addEventListener('mouseleave', hideInventoryTooltip); + + slot.appendChild(iconContainer); + return slot; +} + +/** + * Handle live inventory delta updates from WebSocket. + * Updates the inventory grid for a character if their inventory window is open. + */ +function updateInventoryLive(delta) { + const name = delta.character_name; + const win = inventoryWindows[name]; + if (!win) return; // No inventory window open for this character + + const grid = win.querySelector('.inventory-grid'); + if (!grid) return; + + if (delta.action === 'remove') { + const itemId = delta.item_id; + const existing = grid.querySelector(`[data-item-id="${itemId}"]`); + if (existing) existing.remove(); + } else if (delta.action === 'add') { + const newSlot = createInventorySlot(delta.item); + grid.appendChild(newSlot); + } else if (delta.action === 'update') { + const itemId = delta.item.Id || delta.item.id; + const existing = grid.querySelector(`[data-item-id="${itemId}"]`); + if (existing) { + const newSlot = createInventorySlot(delta.item); + existing.replaceWith(newSlot); + } else { + const newSlot = createInventorySlot(delta.item); + grid.appendChild(newSlot); + } + } + + // Update item count + const countEl = win.querySelector('.inventory-count'); + if (countEl) { + const slotCount = grid.querySelectorAll('.inventory-slot').length; + countEl.textContent = `${slotCount} items`; + } +} + function showInventoryWindow(name) { debugLog('showInventoryWindow called for:', name); const windowId = `inventoryWindow-${name}`; @@ -937,139 +1106,7 @@ function showInventoryWindow(name) { // Render each item data.items.forEach(item => { - - const slot = document.createElement('div'); - slot.className = 'inventory-slot'; - - // Create layered icon container - const iconContainer = document.createElement('div'); - iconContainer.className = 'item-icon-composite'; - - // Get base icon ID with portal.dat offset - const baseIconId = (item.icon + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - - // Check for overlay and underlay from enhanced format or legacy format - let overlayIconId = null; - let underlayIconId = null; - - // Enhanced format (inventory service) - check for proper icon overlay/underlay properties - if (item.icon_overlay_id && item.icon_overlay_id > 0) { - overlayIconId = (item.icon_overlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - if (item.icon_underlay_id && item.icon_underlay_id > 0) { - underlayIconId = (item.icon_underlay_id + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - // Fallback: Enhanced format (inventory service) - check spells object for decal info - if (!overlayIconId && !underlayIconId && item.spells && typeof item.spells === 'object') { - // Icon overlay (using the actual property names from the data) - // Only use valid icon IDs (must be > 100 to avoid invalid small IDs) - if (item.spells.spell_decal_218103838 && item.spells.spell_decal_218103838 > 100) { - overlayIconId = (item.spells.spell_decal_218103838 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - // Icon underlay - if (item.spells.spell_decal_218103848 && item.spells.spell_decal_218103848 > 100) { - underlayIconId = (item.spells.spell_decal_218103848 + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } else if (item.item_data) { - // Legacy format - parse item_data - try { - const itemData = typeof item.item_data === 'string' ? JSON.parse(item.item_data) : item.item_data; - - if (itemData.IntValues) { - // Icon overlay (ID 218103849) - only use valid icon IDs - if (itemData.IntValues['218103849'] && itemData.IntValues['218103849'] > 100) { - overlayIconId = (itemData.IntValues['218103849'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - - // Icon underlay (ID 218103850) - only use valid icon IDs - if (itemData.IntValues['218103850'] && itemData.IntValues['218103850'] > 100) { - underlayIconId = (itemData.IntValues['218103850'] + 0x06000000).toString(16).toUpperCase().padStart(8, '0'); - } - } - } catch (e) { - console.warn('Failed to parse item data for', item.name); - } - } - - // Create underlay (bottom layer) - if (underlayIconId) { - const underlayImg = document.createElement('img'); - underlayImg.className = 'icon-underlay'; - underlayImg.src = `/icons/${underlayIconId}.png`; - underlayImg.alt = 'underlay'; - underlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(underlayImg); - } - - // Create base icon (middle layer) - const baseImg = document.createElement('img'); - baseImg.className = 'icon-base'; - baseImg.src = `/icons/${baseIconId}.png`; - baseImg.alt = item.name || 'Unknown Item'; - baseImg.onerror = function() { - // Final fallback - this.src = '/icons/06000133.png'; - }; - iconContainer.appendChild(baseImg); - - // Create overlay (top layer) - if (overlayIconId) { - const overlayImg = document.createElement('img'); - overlayImg.className = 'icon-overlay'; - overlayImg.src = `/icons/${overlayIconId}.png`; - overlayImg.alt = 'overlay'; - overlayImg.onerror = function() { this.style.display = 'none'; }; - iconContainer.appendChild(overlayImg); - } - - // Create tooltip data - slot.dataset.name = item.name || 'Unknown Item'; - slot.dataset.value = item.value || 0; - slot.dataset.burden = item.burden || 0; - - // Store enhanced data for tooltips - // All data now comes from inventory service (no more local fallback) - if (item.max_damage !== undefined || item.object_class_name !== undefined || item.spells !== undefined) { - // Inventory service provides clean, structured data with translations - // Only include properties that actually exist on the item - const enhancedData = {}; - - // Check all possible enhanced properties from inventory service - const possibleProps = [ - 'max_damage', 'armor_level', 'damage_bonus', 'attack_bonus', - 'wield_level', 'skill_level', 'lore_requirement', 'equip_skill', 'equip_skill_name', - 'material', 'material_name', 'material_id', 'imbue', 'item_set', 'tinks', - 'workmanship', 'workmanship_text', 'damage_rating', 'crit_rating', - 'heal_boost_rating', 'has_id_data', 'object_class_name', 'spells', - 'enhanced_properties', 'damage_range', 'damage_type', 'min_damage', - 'speed_text', 'speed_value', 'mana_display', 'spellcraft', 'current_mana', 'max_mana', - 'melee_defense_bonus', 'magic_defense_bonus', 'missile_defense_bonus', - 'elemental_damage_vs_monsters', 'mana_conversion_bonus', 'icon_overlay_id', 'icon_underlay_id' - ]; - - // Only add properties that exist and have meaningful values - possibleProps.forEach(prop => { - if (item.hasOwnProperty(prop) && item[prop] !== undefined && item[prop] !== null) { - enhancedData[prop] = item[prop]; - } - }); - - slot.dataset.enhancedData = JSON.stringify(enhancedData); - } else { - // No enhanced data available - slot.dataset.enhancedData = JSON.stringify({}); - } - - // Add tooltip on hover - slot.addEventListener('mouseenter', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mousemove', e => showInventoryTooltip(e, slot)); - slot.addEventListener('mouseleave', hideInventoryTooltip); - - slot.appendChild(iconContainer); - grid.appendChild(slot); + grid.appendChild(createInventorySlot(item)); }); invContent.appendChild(grid); @@ -2301,6 +2338,8 @@ function initWebSocket() { } else if (msg.type === 'character_stats') { characterStats[msg.character_name] = msg; updateCharacterWindow(msg.character_name, msg); + } else if (msg.type === 'inventory_delta') { + updateInventoryLive(msg); } else if (msg.type === 'server_status') { handleServerStatusUpdate(msg); }