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:
+121
-32
@@ -8,6 +8,12 @@
|
||||
*/
|
||||
|
||||
import { PresetImportExportManager } from '../../core/PresetImportExportManager.js';
|
||||
import { escapeHtml } from '../../utils/html.js';
|
||||
|
||||
// Maximum file size: 5MB
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
// Allowed MIME types for JSON files
|
||||
const ALLOWED_MIME_TYPES = new Set(['application/json', 'text/plain', '']);
|
||||
|
||||
// Conditional base class — test environment lacks foundry globals.
|
||||
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||
@@ -48,6 +54,9 @@ export class PresetImportDialog extends _AppBase {
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
|
||||
if (!options || typeof options !== 'object') {
|
||||
throw new TypeError('PresetImportDialog: options argument is required and must be an object');
|
||||
}
|
||||
if (!options.adapter || typeof options.adapter !== 'object') {
|
||||
throw new TypeError('PresetImportDialog: adapter option is required and must be an object');
|
||||
}
|
||||
@@ -68,6 +77,10 @@ export class PresetImportDialog extends _AppBase {
|
||||
this._previewItems = [];
|
||||
/** @type {boolean} */
|
||||
this._requiresConfirmation = false;
|
||||
|
||||
// Event listener tracking for cleanup
|
||||
/** @type {Array<{element: Element, type: string, listener: EventListener}>} */
|
||||
this._eventListeners = [];
|
||||
}
|
||||
|
||||
static DEFAULT_OPTIONS = {
|
||||
@@ -111,41 +124,75 @@ export class PresetImportDialog extends _AppBase {
|
||||
const root = this.element;
|
||||
if (!root) return;
|
||||
|
||||
// Clean up previous event listeners to prevent memory leaks
|
||||
for (const { element, type, listener } of this._eventListeners) {
|
||||
element.removeEventListener(type, listener);
|
||||
}
|
||||
this._eventListeners = [];
|
||||
|
||||
// File input change handler
|
||||
root.querySelector('.sp-file-input')?.addEventListener('change', (event) => {
|
||||
this._onFileSelected(event);
|
||||
});
|
||||
const fileInput = root.querySelector('.sp-file-input');
|
||||
if (fileInput) {
|
||||
const handler = (event) => this._onFileSelected(event);
|
||||
fileInput.addEventListener('change', handler);
|
||||
this._eventListeners.push({ element: fileInput, type: 'change', listener: handler });
|
||||
}
|
||||
|
||||
// Mode radio button handlers
|
||||
root.querySelector('.sp-mode-merge')?.addEventListener('change', () => {
|
||||
this._mode = 'merge';
|
||||
this._requiresConfirmation = false;
|
||||
this.render();
|
||||
});
|
||||
const mergeRadio = root.querySelector('.sp-mode-merge');
|
||||
if (mergeRadio) {
|
||||
const handler = () => {
|
||||
this._mode = 'merge';
|
||||
this._requiresConfirmation = false;
|
||||
this.render();
|
||||
};
|
||||
mergeRadio.addEventListener('change', handler);
|
||||
this._eventListeners.push({ element: mergeRadio, type: 'change', listener: handler });
|
||||
}
|
||||
|
||||
root.querySelector('.sp-mode-replace')?.addEventListener('change', () => {
|
||||
this._mode = 'replace';
|
||||
this._requiresConfirmation = true;
|
||||
this.render();
|
||||
});
|
||||
const replaceRadio = root.querySelector('.sp-mode-replace');
|
||||
if (replaceRadio) {
|
||||
const handler = () => {
|
||||
this._mode = 'replace';
|
||||
this._requiresConfirmation = true;
|
||||
this.render();
|
||||
};
|
||||
replaceRadio.addEventListener('change', handler);
|
||||
this._eventListeners.push({ element: replaceRadio, type: 'change', listener: handler });
|
||||
}
|
||||
|
||||
// Import button click handler
|
||||
root.querySelector('.sp-import-btn:not(.sp-confirm-btn)')?.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await this._onImport();
|
||||
});
|
||||
const importBtn = root.querySelector('.sp-import-btn:not(.sp-confirm-btn)');
|
||||
if (importBtn) {
|
||||
const handler = async (event) => {
|
||||
event.preventDefault();
|
||||
await this._onImport();
|
||||
};
|
||||
importBtn.addEventListener('click', handler);
|
||||
this._eventListeners.push({ element: importBtn, type: 'click', listener: handler });
|
||||
}
|
||||
|
||||
// Confirm button handler (for replace mode)
|
||||
root.querySelector('.sp-confirm-btn')?.addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await this._onConfirmImport();
|
||||
});
|
||||
const confirmBtn = root.querySelector('.sp-confirm-btn');
|
||||
if (confirmBtn) {
|
||||
const handler = async (event) => {
|
||||
event.preventDefault();
|
||||
await this._onConfirmImport();
|
||||
};
|
||||
confirmBtn.addEventListener('click', handler);
|
||||
this._eventListeners.push({ element: confirmBtn, type: 'click', listener: handler });
|
||||
}
|
||||
|
||||
// Cancel button handler
|
||||
root.querySelector('.sp-cancel-btn')?.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
});
|
||||
const cancelBtn = root.querySelector('.sp-cancel-btn');
|
||||
if (cancelBtn) {
|
||||
const handler = (event) => {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
};
|
||||
cancelBtn.addEventListener('click', handler);
|
||||
this._eventListeners.push({ element: cancelBtn, type: 'click', listener: handler });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +201,17 @@ export class PresetImportDialog extends _AppBase {
|
||||
*/
|
||||
async _onClose(options) {
|
||||
await super._onClose?.(options);
|
||||
|
||||
// Clean up event listeners
|
||||
for (const { element, type, listener } of this._eventListeners) {
|
||||
try {
|
||||
element.removeEventListener(type, listener);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
this._eventListeners = [];
|
||||
|
||||
// Clean up any references
|
||||
this._exportManager = null;
|
||||
this._scenePresetManager = null;
|
||||
@@ -178,7 +236,33 @@ export class PresetImportDialog extends _AppBase {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedFile = input.files[0];
|
||||
const file = input.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
||||
// Check file extension as fallback
|
||||
const validExtensions = ['.json'];
|
||||
const hasValidExtension = validExtensions.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
if (!hasValidExtension) {
|
||||
if (this._adapter.notifications) {
|
||||
this._adapter.notifications.error('Please select a JSON file (.json extension required)');
|
||||
}
|
||||
// Clear the input so user can select again
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
if (this._adapter.notifications) {
|
||||
this._adapter.notifications.error(`File is too large (${Math.round(file.size / 1024 / 1024)}MB). Maximum size is ${Math.round(MAX_FILE_SIZE / 1024 / 1024)}MB.`);
|
||||
}
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedFile = file;
|
||||
this._previewItems = [];
|
||||
this._parseAndPreviewFile();
|
||||
}
|
||||
@@ -218,7 +302,7 @@ export class PresetImportDialog extends _AppBase {
|
||||
}
|
||||
} catch (err) {
|
||||
valid = false;
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
error = escapeHtml(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
this._previewItems.push({ name, valid, error });
|
||||
@@ -234,7 +318,7 @@ export class PresetImportDialog extends _AppBase {
|
||||
this.render();
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this._previewItems = [{ name: this._selectedFile.name, valid: false, error: errorMsg }];
|
||||
this._previewItems = [{ name: this._selectedFile.name, valid: false, error: escapeHtml(errorMsg) }];
|
||||
this._requiresConfirmation = false;
|
||||
this.render();
|
||||
}
|
||||
@@ -314,9 +398,14 @@ export class PresetImportDialog extends _AppBase {
|
||||
const originalLabel = btn.innerHTML;
|
||||
|
||||
try {
|
||||
// Disable button and show loading state
|
||||
// Disable button and show loading state (using textContent for safety)
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...';
|
||||
btn.textContent = '';
|
||||
const spinner = document.createElement('i');
|
||||
spinner.className = 'fas fa-spinner fa-spin';
|
||||
const text = document.createTextNode(' Importing...');
|
||||
btn.appendChild(spinner);
|
||||
btn.appendChild(text);
|
||||
|
||||
// Read and import file
|
||||
const content = await this._readFileAsText(this._selectedFile);
|
||||
@@ -329,7 +418,7 @@ export class PresetImportDialog extends _AppBase {
|
||||
this.close();
|
||||
} else {
|
||||
// Show errors
|
||||
const errorMessages = result.errors.join('\n');
|
||||
const errorMessages = result.errors.map(e => escapeHtml(e)).join('\n');
|
||||
if (this._adapter.notifications) {
|
||||
this._adapter.notifications.error('Failed to import presets\n' + errorMessages);
|
||||
}
|
||||
@@ -337,7 +426,7 @@ export class PresetImportDialog extends _AppBase {
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
if (this._adapter.notifications) {
|
||||
this._adapter.notifications.error('Failed to import presets: ' + errorMsg);
|
||||
this._adapter.notifications.error('Failed to import presets: ' + escapeHtml(errorMsg));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
|
||||
Reference in New Issue
Block a user