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 <noreply@anthropic.com>
This commit is contained in:
parent
749652d534
commit
f145e6e131
2 changed files with 201 additions and 148 deletions
|
|
@ -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(
|
||||
|
|
|
|||
305
static/script.js
305
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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue