Fix code review findings for Story 3.3: Preset Import & Export
Security & Quality Improvements:
- Fix XSS vulnerabilities in PresetImportDialog, PresetExportDialog, and templates
- Add resource leak protection in downloadExportFile() with try/finally
- Fix encapsulation violation by using public API instead of _presetsCache
- Add rollback mechanism for partial failures in replace mode
- Add preset name validation (length, characters, empty check)
- Add duplicate name detection within import files
- Add file size validation (5MB limit) and type validation
- Fix event listener leaks with proper cleanup in _onRender/_onClose
- Add constructor parameter validation for all dialogs
Acceptance Criteria Compliance:
- Fix AC-2: Export filename now uses world name (via parent.name)
- Fix AC-6: Error message matches spec exactly ('Import failed: invalid JSON format')
- Fix AC-8: Merge/Replace messages match spec format
Code Quality:
- Add shared HTML escaping utilities (src/utils/html.js)
- Consolidate duplicate localization strings (removed 28 duplicates from SCRYING_POOL)
- Use SCENE_PRESET_VERSION constant instead of hardcoded 1
- Handle null options in importPresets()
- Graceful handling of skipValidation with invalid data
Test Results: 679 passed, 3 failed (pre-existing in DirectorsBoard)
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
* @module core/PresetImportExportManager
|
||||
*/
|
||||
|
||||
import { isValidScenePreset, MAX_PRESETS_PER_WORLD } from '../contracts/scene-preset.js';
|
||||
import { isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js';
|
||||
|
||||
/**
|
||||
* Result of an import operation.
|
||||
@@ -26,9 +26,9 @@ import { isValidScenePreset, MAX_PRESETS_PER_WORLD } from '../contracts/scene-pr
|
||||
/**
|
||||
* Export data structure.
|
||||
* @typedef {Object} ExportData
|
||||
* @property {1} _version - Export schema version.
|
||||
* @property {string} worldName - Name of the world being exported from.
|
||||
* @property {number} exportedAt - Timestamp when export was created.
|
||||
* @property {number} _version - Export schema version.
|
||||
* @property {string} [worldName] - Name of the world being exported from (optional).
|
||||
* @property {number} [exportedAt] - Timestamp when export was created (optional).
|
||||
* @property {Object.<string, import('../contracts/scene-preset.js').ScenePreset>} presets - All presets by name.
|
||||
*/
|
||||
|
||||
@@ -51,6 +51,19 @@ export class PresetImportExportManager {
|
||||
throw new TypeError('PresetImportExportManager: scenePresetManager argument is required and must be an object');
|
||||
}
|
||||
|
||||
// Validate adapter has required methods
|
||||
if (typeof adapter.scenes?.current !== 'function') {
|
||||
throw new TypeError('PresetImportExportManager: adapter.scenes.current must be a function');
|
||||
}
|
||||
|
||||
// Validate scenePresetManager has required methods
|
||||
const requiredMethods = ['list', 'save', 'delete', 'get'];
|
||||
for (const method of requiredMethods) {
|
||||
if (typeof scenePresetManager[method] !== 'function') {
|
||||
throw new TypeError(`PresetImportExportManager: scenePresetManager.${method} must be a function`);
|
||||
}
|
||||
}
|
||||
|
||||
this._adapter = adapter;
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
}
|
||||
@@ -68,11 +81,11 @@ export class PresetImportExportManager {
|
||||
}
|
||||
|
||||
const presets = this._scenePresetManager.list();
|
||||
const worldName = currentScene.name ?? 'unknown-world';
|
||||
const worldName = this._adapter.scenes.current?.()?.parent?.name ?? currentScene.name ?? 'world';
|
||||
|
||||
/** @type {ExportData} */
|
||||
const exportData = {
|
||||
_version: 1,
|
||||
_version: SCENE_PRESET_VERSION,
|
||||
worldName,
|
||||
exportedAt: Date.now(),
|
||||
presets: {},
|
||||
@@ -119,12 +132,16 @@ export class PresetImportExportManager {
|
||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
try {
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
} finally {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,8 +164,8 @@ export class PresetImportExportManager {
|
||||
}
|
||||
|
||||
// Check schema version
|
||||
if (obj._version !== 1) {
|
||||
throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected 1`);
|
||||
if (obj._version !== SCENE_PRESET_VERSION) {
|
||||
throw new TypeError(`Import data: unsupported schema version ${obj._version}, expected ${SCENE_PRESET_VERSION}`);
|
||||
}
|
||||
|
||||
// Check for presets
|
||||
@@ -156,30 +173,73 @@ export class PresetImportExportManager {
|
||||
throw new TypeError('Import data: missing or invalid presets object');
|
||||
}
|
||||
|
||||
// Validate optional worldName
|
||||
if (obj.worldName !== undefined && typeof obj.worldName !== 'string') {
|
||||
throw new TypeError('Import data: worldName must be a string if present');
|
||||
// Validate optional worldName - must be non-empty string if present
|
||||
if (obj.worldName !== undefined) {
|
||||
if (typeof obj.worldName !== 'string') {
|
||||
throw new TypeError('Import data: worldName must be a string if present');
|
||||
}
|
||||
if (obj.worldName.length === 0) {
|
||||
throw new TypeError('Import data: worldName must be a non-empty string if present');
|
||||
}
|
||||
if (obj.worldName.length > 250) {
|
||||
throw new TypeError('Import data: worldName exceeds maximum length of 250 characters');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate optional exportedAt
|
||||
if (obj.exportedAt !== undefined && (typeof obj.exportedAt !== 'number' || !Number.isFinite(obj.exportedAt))) {
|
||||
throw new TypeError('Import data: exportedAt must be a finite number if present');
|
||||
// Validate optional exportedAt - must be non-negative finite number if present
|
||||
if (obj.exportedAt !== undefined) {
|
||||
if (typeof obj.exportedAt !== 'number' || !Number.isFinite(obj.exportedAt)) {
|
||||
throw new TypeError('Import data: exportedAt must be a finite number if present');
|
||||
}
|
||||
if (obj.exportedAt < 0) {
|
||||
throw new TypeError('Import data: exportedAt must be a non-negative number if present');
|
||||
}
|
||||
}
|
||||
|
||||
return /** @type {ExportData} */ (obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and validates individual presets from validated import data.
|
||||
* Extracts and validates individual presets from import data.
|
||||
*
|
||||
* @param {ExportData} data - Validated import data.
|
||||
* @param {ExportData} data - Import data (may be partially validated if skipValidation was used).
|
||||
* @returns {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} Array of extraction results.
|
||||
* @private
|
||||
*/
|
||||
_extractAndValidatePresets(data) {
|
||||
const results = [];
|
||||
const seenNames = new Set();
|
||||
|
||||
// Handle missing or invalid presets
|
||||
if (!data.presets || typeof data.presets !== 'object' || Array.isArray(data.presets)) {
|
||||
return results; // Return empty array - errors will be caught by validation
|
||||
}
|
||||
|
||||
for (const [name, presetData] of Object.entries(data.presets)) {
|
||||
// Check for duplicate names in the import file
|
||||
if (seenNames.has(name)) {
|
||||
results.push({ name, preset: null, error: `Preset "${name}": duplicate name in import file` });
|
||||
continue;
|
||||
}
|
||||
seenNames.add(name);
|
||||
|
||||
// Validate preset name
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
results.push({ name: String(name), preset: null, error: `Preset: name must be a non-empty string, got "${String(name)}"` });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name.length > 100) {
|
||||
results.push({ name, preset: null, error: `Preset "${name}": name exceeds maximum length of 100 characters` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate preset name characters (alphanumeric, dash, underscore, space, dot)
|
||||
if (!/^[a-zA-Z0-9\s._-]+$/.test(name)) {
|
||||
results.push({ name, preset: null, error: `Preset "${name}": name contains invalid characters (only alphanumeric, space, dot, dash, underscore allowed)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const validated = isValidScenePreset(presetData);
|
||||
results.push({ name, preset: validated, error: null });
|
||||
@@ -203,6 +263,9 @@ export class PresetImportExportManager {
|
||||
* @throws {TypeError} If JSON parsing fails or mode is invalid.
|
||||
*/
|
||||
async importPresets(jsonString, mode, options = {}) {
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('PresetImportExportManager.importPresets: options must be an object');
|
||||
}
|
||||
const { skipValidation = false } = options;
|
||||
|
||||
// Validate mode
|
||||
@@ -214,8 +277,8 @@ export class PresetImportExportManager {
|
||||
let parsedData;
|
||||
try {
|
||||
parsedData = JSON.parse(jsonString);
|
||||
} catch (err) {
|
||||
throw new TypeError(`Import failed: invalid JSON format - ${err instanceof Error ? err.message : String(err)}`);
|
||||
} catch {
|
||||
throw new TypeError('Import failed: invalid JSON format');
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
@@ -286,6 +349,7 @@ export class PresetImportExportManager {
|
||||
|
||||
/**
|
||||
* Merges presets into current scene - adds new presets, skips duplicates.
|
||||
* Uses public API to ensure proper validation and persistence.
|
||||
*
|
||||
* @param {ExportData} data - Validated import data.
|
||||
* @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import.
|
||||
@@ -303,8 +367,8 @@ export class PresetImportExportManager {
|
||||
skipped++;
|
||||
} else {
|
||||
try {
|
||||
// Add preset directly to cache
|
||||
this._scenePresetManager._presetsCache.set(name, preset);
|
||||
// Use public save method instead of direct cache manipulation
|
||||
await this._scenePresetManager.save(name, preset);
|
||||
added++;
|
||||
} catch (err) {
|
||||
errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -312,14 +376,7 @@ export class PresetImportExportManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Persist changes after all presets processed
|
||||
try {
|
||||
await this._scenePresetManager._saveScenePresets();
|
||||
} catch (err) {
|
||||
errors.push(`Failed to persist merged presets: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
const message = `Imported ${added} presets (${skipped} skipped as duplicates)`;
|
||||
const message = `Imported ${validPresets.length} presets (${added} new, ${skipped} skipped as duplicates)`;
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
message,
|
||||
@@ -332,6 +389,8 @@ export class PresetImportExportManager {
|
||||
|
||||
/**
|
||||
* Replaces all existing presets with imported ones.
|
||||
* Uses a rollback mechanism: if any add fails after deletions, attempts to restore.
|
||||
* Uses public API to ensure proper validation and persistence.
|
||||
*
|
||||
* @param {ExportData} data - Validated import data.
|
||||
* @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import.
|
||||
@@ -342,35 +401,56 @@ export class PresetImportExportManager {
|
||||
async _replacePresets(data, validPresets, existingCount) {
|
||||
let added = 0;
|
||||
const errors = [];
|
||||
/** @type {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset}>} */
|
||||
const deletedPresets = [];
|
||||
|
||||
// Clear existing presets
|
||||
const existingNames = Array.from(this._scenePresetManager._presetsCache.keys());
|
||||
// Step 1: Delete all existing presets, saving them for potential rollback
|
||||
const existingNames = this._scenePresetManager.list().map(p => p.name);
|
||||
for (const name of existingNames) {
|
||||
try {
|
||||
const preset = this._scenePresetManager.get(name);
|
||||
if (preset) {
|
||||
deletedPresets.push({ name, preset });
|
||||
}
|
||||
await this._scenePresetManager.delete(name);
|
||||
} catch (err) {
|
||||
errors.push(`Failed to delete existing preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add all imported presets
|
||||
// Step 2: Add all imported presets
|
||||
const successfullyAdded = [];
|
||||
for (const { name, preset } of validPresets) {
|
||||
try {
|
||||
this._scenePresetManager._presetsCache.set(name, preset);
|
||||
await this._scenePresetManager.save(name, preset);
|
||||
successfullyAdded.push({ name, preset });
|
||||
added++;
|
||||
} catch (err) {
|
||||
errors.push(`Failed to add preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist changes
|
||||
try {
|
||||
await this._scenePresetManager._saveScenePresets();
|
||||
} catch (err) {
|
||||
errors.push(`Failed to persist replaced presets: ${err instanceof Error ? err.message : String(err)}`);
|
||||
// Step 3: If there were errors, attempt rollback
|
||||
if (errors.length > 0 && deletedPresets.length > 0) {
|
||||
const rollbackErrors = [];
|
||||
for (const { name, preset } of deletedPresets) {
|
||||
try {
|
||||
await this._scenePresetManager.save(name, preset);
|
||||
} catch (err) {
|
||||
rollbackErrors.push(`Failed to rollback preset "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
if (rollbackErrors.length > 0) {
|
||||
errors.push(...rollbackErrors);
|
||||
} else {
|
||||
errors.push('Partial import failure - rolled back to previous state');
|
||||
}
|
||||
}
|
||||
|
||||
const message = `Replaced all ${existingCount} presets with ${added} from import`;
|
||||
const message = errors.length === 0
|
||||
? `Replaced all presets with ${added} from import`
|
||||
: `Partial import: added ${added} of ${validPresets.length} (rolled back)`;
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
message,
|
||||
|
||||
Reference in New Issue
Block a user