Merge pull request #13 from paperboygold/feature/inventory-bugfixes
feat: inventory bugfixes, status polish, editable inventory fields
This commit is contained in:
+203
-36
@@ -3,17 +3,46 @@
|
||||
* Utilities for parsing item strings into arrays and vice versa
|
||||
*/
|
||||
|
||||
import { sanitizeItemName, MAX_ITEMS_PER_SECTION } from './security.js';
|
||||
|
||||
/**
|
||||
* Parses a comma-separated item string into an array of trimmed item names.
|
||||
* Filters out empty strings and handles "None" gracefully.
|
||||
* Smart handling: collapses newlines inside parentheses, preserves them outside.
|
||||
* Parses item strings from AI responses into clean arrays.
|
||||
* Handles numerous AI formatting quirks and edge cases.
|
||||
*
|
||||
* @param {string} itemString - Comma-separated items (e.g., "Sword, Shield, 3x Potions")
|
||||
* @returns {string[]} Array of item names, or empty array if none
|
||||
* Smart handling:
|
||||
* - Strips wrapping brackets/braces: [], {}, [[]]
|
||||
* - Strips wrapping quotes: "...", '...'
|
||||
* - Converts newlines to commas (newline-based lists)
|
||||
* - Strips markdown: **bold**, *italic*, `code`, ~~strikethrough~~
|
||||
* - Strips list markers: -, •, 1., 2., etc.
|
||||
* - Collapses newlines inside parentheses to spaces
|
||||
* - Only splits on commas OUTSIDE parentheses (preserves commas in descriptions)
|
||||
* - Gracefully handles unmatched parentheses
|
||||
*
|
||||
* @param {string} itemString - Item string from AI (various formats supported)
|
||||
* @returns {string[]} Array of clean item names, or empty array if none
|
||||
*
|
||||
* @example
|
||||
* // Standard comma-separated
|
||||
* parseItems("Sword, Shield, 3x Potions") // ["Sword", "Shield", "3x Potions"]
|
||||
* parseItems("Books (magical\ntomes), Sword") // ["Books (magical tomes)", "Sword"]
|
||||
*
|
||||
* // Newline-based lists
|
||||
* parseItems("Sword\nShield\nPotion") // ["Sword", "Shield", "Potion"]
|
||||
* parseItems("- Sword\n- Shield") // ["Sword", "Shield"]
|
||||
* parseItems("1. Sword\n2. Shield") // ["Sword", "Shield"]
|
||||
*
|
||||
* // Commas in parentheses (preserved)
|
||||
* parseItems("Potato (Cursed, Sexy, Your Mum & Dick, Etc), Sword")
|
||||
* // → ["Potato (Cursed, Sexy, Your Mum & Dick, Etc)", "Sword"]
|
||||
*
|
||||
* // Markdown formatting (stripped)
|
||||
* parseItems("**Sword** (equipped), *Shield*") // ["Sword (equipped)", "Shield"]
|
||||
*
|
||||
* // Various brackets (stripped)
|
||||
* parseItems("[Sword, Shield]") // ["Sword", "Shield"]
|
||||
* parseItems("{Sword, Shield}") // ["Sword", "Shield"]
|
||||
*
|
||||
* // Edge cases
|
||||
* parseItems("None") // []
|
||||
* parseItems("") // []
|
||||
* parseItems(null) // []
|
||||
@@ -24,44 +53,182 @@ export function parseItems(itemString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Trim and check for "None" (case-insensitive)
|
||||
const trimmed = itemString.trim();
|
||||
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
|
||||
let processed = itemString.trim();
|
||||
|
||||
// Quick check for "None" or empty
|
||||
if (processed === '' || processed.toLowerCase() === 'none') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Collapse newlines inside parentheses
|
||||
let processed = '';
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
processed += char;
|
||||
} else if (char === ')') {
|
||||
parenDepth--;
|
||||
processed += char;
|
||||
} else if ((char === '\n' || char === '\r') && parenDepth > 0) {
|
||||
// Inside parentheses: replace newline with space
|
||||
// Skip if previous char was already a space
|
||||
if (processed[processed.length - 1] !== ' ') {
|
||||
processed += ' ';
|
||||
}
|
||||
} else {
|
||||
processed += char;
|
||||
// STEP 1: Strip wrapping brackets/braces (AI sometimes wraps entire lists)
|
||||
// Handle: [], {}, [[]], etc.
|
||||
while (
|
||||
(processed.startsWith('[') && processed.endsWith(']')) ||
|
||||
(processed.startsWith('{') && processed.endsWith('}'))
|
||||
) {
|
||||
processed = processed.slice(1, -1).trim();
|
||||
if (processed === '' || processed.toLowerCase() === 'none') {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up multiple consecutive spaces
|
||||
// STEP 2: Strip wrapping quotes (AI sometimes quotes entire lists)
|
||||
// Handle: "...", '...'
|
||||
if ((processed.startsWith('"') && processed.endsWith('"')) ||
|
||||
(processed.startsWith("'") && processed.endsWith("'"))) {
|
||||
processed = processed.slice(1, -1).trim();
|
||||
if (processed === '' || processed.toLowerCase() === 'none') {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 3: Convert newlines to commas (OUTSIDE parentheses)
|
||||
// Handles newline-based lists: "Sword\nShield\nPotion" → "Sword, Shield, Potion"
|
||||
let withCommas = '';
|
||||
let parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < processed.length; i++) {
|
||||
const char = processed[i];
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
withCommas += char;
|
||||
} else if (char === ')') {
|
||||
parenDepth--;
|
||||
withCommas += char;
|
||||
} else if ((char === '\n' || char === '\r') && parenDepth === 0) {
|
||||
// Newline outside parentheses - convert to comma separator
|
||||
// Don't add if previous char was already a separator
|
||||
const prevChar = withCommas[withCommas.length - 1];
|
||||
if (prevChar && prevChar !== ',' && prevChar !== '\n') {
|
||||
withCommas += ',';
|
||||
}
|
||||
} else if ((char === '\n' || char === '\r') && parenDepth > 0) {
|
||||
// Newline inside parentheses - convert to space
|
||||
if (withCommas[withCommas.length - 1] !== ' ') {
|
||||
withCommas += ' ';
|
||||
}
|
||||
} else {
|
||||
withCommas += char;
|
||||
}
|
||||
}
|
||||
processed = withCommas;
|
||||
|
||||
// STEP 4: Strip markdown formatting
|
||||
// Remove: **bold**, *italic*, `code`, ~~strikethrough~~
|
||||
processed = processed
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1') // **bold** → bold
|
||||
.replace(/\*(.+?)\*/g, '$1') // *italic* → italic
|
||||
.replace(/`(.+?)`/g, '$1') // `code` → code
|
||||
.replace(/~~(.+?)~~/g, '$1'); // ~~strike~~ → strike
|
||||
|
||||
// STEP 5: Normalize whitespace
|
||||
processed = processed.replace(/\s+/g, ' ');
|
||||
|
||||
// Split by comma, trim each item, filter empties
|
||||
return processed
|
||||
.split(',')
|
||||
.map(item => item.trim())
|
||||
.filter(item => item !== '' && item.toLowerCase() !== 'none');
|
||||
// STEP 6: Smart comma splitting (only split on commas OUTSIDE parentheses)
|
||||
// Also handles list markers, quotes, and security validation per-item
|
||||
const items = [];
|
||||
let currentItem = '';
|
||||
parenDepth = 0;
|
||||
|
||||
for (let i = 0; i < processed.length; i++) {
|
||||
const char = processed[i];
|
||||
|
||||
if (char === '(') {
|
||||
parenDepth++;
|
||||
currentItem += char;
|
||||
} else if (char === ')') {
|
||||
parenDepth--;
|
||||
// Graceful handling: don't let depth go negative
|
||||
if (parenDepth < 0) {
|
||||
console.warn('[RPG Companion] Unmatched closing parenthesis in item parsing');
|
||||
parenDepth = 0;
|
||||
}
|
||||
currentItem += char;
|
||||
} else if (char === ',' && parenDepth === 0) {
|
||||
// Comma outside parentheses - this is a separator
|
||||
const cleaned = cleanSingleItem(currentItem);
|
||||
if (cleaned) {
|
||||
// Security check: validate and sanitize item name
|
||||
const sanitized = sanitizeItemName(cleaned);
|
||||
if (sanitized) {
|
||||
items.push(sanitized);
|
||||
}
|
||||
|
||||
// DoS protection: enforce max items limit
|
||||
if (items.length >= MAX_ITEMS_PER_SECTION) {
|
||||
console.warn(`[RPG Companion] Reached max items limit (${MAX_ITEMS_PER_SECTION}), truncating list`);
|
||||
return items;
|
||||
}
|
||||
}
|
||||
currentItem = ''; // Start new item
|
||||
} else {
|
||||
currentItem += char;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last item
|
||||
const cleaned = cleanSingleItem(currentItem);
|
||||
if (cleaned) {
|
||||
// Security check: validate and sanitize item name
|
||||
const sanitized = sanitizeItemName(cleaned);
|
||||
if (sanitized) {
|
||||
items.push(sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
// Warn if parentheses were unmatched
|
||||
if (parenDepth > 0) {
|
||||
console.warn('[RPG Companion] Unmatched opening parenthesis in item parsing');
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans a single item string (helper for parseItems)
|
||||
* Removes list markers, wrapping quotes, trims, and capitalizes first letter
|
||||
*
|
||||
* @param {string} item - Single item string to clean
|
||||
* @returns {string|null} Cleaned item or null if empty/invalid
|
||||
* @private
|
||||
*/
|
||||
function cleanSingleItem(item) {
|
||||
if (!item || typeof item !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cleaned = item.trim();
|
||||
|
||||
// Filter "None"
|
||||
if (cleaned === '' || cleaned.toLowerCase() === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip list markers: "- Item", "• Item", "1. Item", "2. Item", etc.
|
||||
// Matches: -, •, *, 1., 2., a), etc.
|
||||
cleaned = cleaned.replace(/^[-•*]\s+/, ''); // "- Item" → "Item"
|
||||
cleaned = cleaned.replace(/^\d+\.\s+/, ''); // "1. Item" → "Item"
|
||||
cleaned = cleaned.replace(/^[a-z]\)\s+/i, ''); // "a) Item" → "Item"
|
||||
|
||||
// Strip wrapping quotes from individual items
|
||||
if ((cleaned.startsWith('"') && cleaned.endsWith('"')) ||
|
||||
(cleaned.startsWith("'") && cleaned.endsWith("'"))) {
|
||||
cleaned = cleaned.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
// Final empty check
|
||||
if (cleaned === '' || cleaned.toLowerCase() === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Capitalize first letter for consistency
|
||||
// Preserves rest of string case (e.g., "iPhone" stays "iPhone", not "Iphone")
|
||||
if (cleaned.length > 0) {
|
||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Security Utilities Module
|
||||
* Handles input sanitization and validation to prevent security vulnerabilities
|
||||
*/
|
||||
|
||||
import { parseItems, serializeItems } from './itemParser.js';
|
||||
|
||||
/**
|
||||
* List of dangerous property names that could cause prototype pollution
|
||||
* or shadow critical object methods.
|
||||
* @private
|
||||
*/
|
||||
const BLOCKED_PROPERTY_NAMES = [
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
'toString',
|
||||
'valueOf',
|
||||
'hasOwnProperty',
|
||||
'__defineGetter__',
|
||||
'__defineSetter__',
|
||||
'__lookupGetter__',
|
||||
'__lookupSetter__'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates and sanitizes storage location names.
|
||||
* Prevents prototype pollution and object property shadowing attacks.
|
||||
*
|
||||
* @param {string} name - Location name to validate
|
||||
* @returns {string|null} Sanitized location name or null if invalid/dangerous
|
||||
*
|
||||
* @example
|
||||
* sanitizeLocationName("Home") // "Home"
|
||||
* sanitizeLocationName("__proto__") // null (blocked, logs warning)
|
||||
* sanitizeLocationName("A".repeat(300)) // "AAA..." (truncated to 200 chars)
|
||||
*/
|
||||
export function sanitizeLocationName(name) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
|
||||
// Empty check
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for dangerous property names (case-insensitive)
|
||||
const lowerName = trimmed.toLowerCase();
|
||||
if (BLOCKED_PROPERTY_NAMES.some(blocked => lowerName === blocked.toLowerCase())) {
|
||||
console.warn(`[RPG Companion] Blocked dangerous location name: "${trimmed}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Max length check (reasonable location name)
|
||||
const MAX_LOCATION_LENGTH = 200;
|
||||
if (trimmed.length > MAX_LOCATION_LENGTH) {
|
||||
console.warn(`[RPG Companion] Location name too long (${trimmed.length} chars), truncating to ${MAX_LOCATION_LENGTH}`);
|
||||
return trimmed.slice(0, MAX_LOCATION_LENGTH);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and sanitizes item names.
|
||||
* Prevents excessively long item names that could cause DoS or UI issues.
|
||||
*
|
||||
* @param {string} name - Item name to validate
|
||||
* @returns {string|null} Sanitized item name or null if invalid
|
||||
*
|
||||
* @example
|
||||
* sanitizeItemName("Sword") // "Sword"
|
||||
* sanitizeItemName("") // null
|
||||
* sanitizeItemName("A".repeat(600)) // "AAA..." (truncated to 500 chars)
|
||||
*/
|
||||
export function sanitizeItemName(name) {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = name.trim();
|
||||
|
||||
// Empty check
|
||||
if (trimmed === '' || trimmed.toLowerCase() === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Max length check (reasonable item name with description)
|
||||
const MAX_ITEM_LENGTH = 500;
|
||||
if (trimmed.length > MAX_ITEM_LENGTH) {
|
||||
console.warn(`[RPG Companion] Item name too long (${trimmed.length} chars), truncating to ${MAX_ITEM_LENGTH}`);
|
||||
return trimmed.slice(0, MAX_ITEM_LENGTH);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and cleans a stored inventory object.
|
||||
* Ensures all keys are safe property names and all values are strings.
|
||||
* Cleans items within each location (removes corrupted/dangerous items).
|
||||
* Preserves empty locations (with "None") so users can add items later.
|
||||
* Prevents prototype pollution attacks via object keys.
|
||||
*
|
||||
* @param {Object} stored - Raw stored inventory object
|
||||
* @returns {Object} Cleaned stored inventory object (always a plain object)
|
||||
*
|
||||
* @example
|
||||
* validateStoredInventory({ "Home": "Sword, Shield" })
|
||||
* // → { "Home": "Sword, Shield" }
|
||||
*
|
||||
* validateStoredInventory({ "Home": "Sword, __proto__, Shield" })
|
||||
* // → { "Home": "Sword, Shield" } (dangerous item removed, logged)
|
||||
*
|
||||
* validateStoredInventory({ "Home": "None" })
|
||||
* // → { "Home": "None" } (empty location preserved)
|
||||
*
|
||||
* validateStoredInventory({ "__proto__": "malicious" })
|
||||
* // → {} (dangerous key removed, logged)
|
||||
*
|
||||
* validateStoredInventory({ "BadLocation": "__proto__, constructor" })
|
||||
* // → { "BadLocation": "None" } (all items removed, location kept empty)
|
||||
*
|
||||
* validateStoredInventory(null)
|
||||
* // → {} (invalid input, returns empty object)
|
||||
*/
|
||||
export function validateStoredInventory(stored) {
|
||||
// Handle invalid input
|
||||
if (!stored || typeof stored !== 'object' || Array.isArray(stored)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const cleaned = {};
|
||||
|
||||
// Validate each property
|
||||
for (const key in stored) {
|
||||
// Only check own properties (not inherited)
|
||||
if (!Object.prototype.hasOwnProperty.call(stored, key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize the location name
|
||||
const sanitizedKey = sanitizeLocationName(key);
|
||||
if (!sanitizedKey) {
|
||||
// Key was invalid or dangerous, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure value is a string
|
||||
const value = stored[key];
|
||||
if (typeof value !== 'string') {
|
||||
console.warn(`[RPG Companion] Invalid stored inventory value for location "${sanitizedKey}", skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clean items within this location (removes corrupted/dangerous items)
|
||||
const cleanedValue = cleanItemString(value);
|
||||
|
||||
// Always keep the location (even if empty/"None")
|
||||
// "None" is a valid state - it means the location exists but has no items yet
|
||||
cleaned[sanitizedKey] = cleanedValue;
|
||||
|
||||
// Warn if we had to clean corrupted items (but only if original wasn't just "None")
|
||||
if (value !== cleanedValue && value.toLowerCase() !== 'none') {
|
||||
console.warn(`[RPG Companion] Cleaned corrupted items from location "${sanitizedKey}": "${value}" → "${cleanedValue}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of items allowed in a single inventory section.
|
||||
* Prevents DoS via extremely large item lists.
|
||||
* @constant {number}
|
||||
*/
|
||||
export const MAX_ITEMS_PER_SECTION = 500;
|
||||
|
||||
/**
|
||||
* Cleans an item string by parsing and re-serializing.
|
||||
* Removes corrupted, dangerous, or invalid items while preserving valid ones.
|
||||
* Applies ALL parsing rules: markdown stripping, sanitization, length limits, etc.
|
||||
*
|
||||
* This is used at LOAD time to clean persisted data immediately, not just at render time.
|
||||
*
|
||||
* @param {string} itemString - Raw item string (possibly corrupted)
|
||||
* @returns {string} Clean item string with only valid items, or "None" if no valid items
|
||||
*
|
||||
* @example
|
||||
* cleanItemString("Sword, Shield") // "Sword, Shield" (unchanged)
|
||||
* cleanItemString("Sword, __proto__, Shield") // "Sword, Shield" (dangerous item removed)
|
||||
* cleanItemString("A".repeat(600) + ", Sword") // "AAA... (truncated), Sword"
|
||||
* cleanItemString("**Sword**, *Shield*") // "Sword, Shield" (markdown stripped)
|
||||
* cleanItemString("__proto__, constructor") // "None" (all items invalid)
|
||||
*/
|
||||
export function cleanItemString(itemString) {
|
||||
// Parse using robust parser (handles all edge cases, sanitizes each item)
|
||||
// This applies: newlines→commas, markdown stripping, parenthesis-aware splitting,
|
||||
// sanitizeItemName() validation, length limits, max items limit
|
||||
const items = parseItems(itemString);
|
||||
|
||||
// If no valid items remain after parsing/sanitization, return "None"
|
||||
if (items.length === 0) {
|
||||
return "None";
|
||||
}
|
||||
|
||||
// Re-serialize clean items back to string format
|
||||
return serializeItems(items);
|
||||
}
|
||||
Reference in New Issue
Block a user