feat: Add HTML and attribute escaping for trigger words and class tokens to prevent XSS vulnerabilities, along with new frontend tests. Fixes #732

This commit is contained in:
Will Miao
2025-12-23 08:47:15 +08:00
parent 3ba5c4c2ab
commit dd89aa49c1
3 changed files with 208 additions and 142 deletions

View File

@@ -1,15 +1,15 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js';
import {
import {
toggleShowcase,
setupShowcaseScroll,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js';
import {
setupModelNameEditing,
setupBaseModelEditing,
import {
setupModelNameEditing,
setupBaseModelEditing,
setupFileNameEditing
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
@@ -242,7 +242,7 @@ export async function showModelModal(model, modelType) {
const modalTitle = model.model_name;
cleanupNavigationShortcuts();
detachModalHandlers(modalId);
// Fetch complete civitai metadata
let completeCivitaiData = model.civitai || {};
if (model.file_path) {
@@ -254,7 +254,7 @@ export async function showModelModal(model, modelType) {
// Continue with existing data if fetch fails
}
}
// Update model with complete civitai data
const modelWithFullData = {
...model,
@@ -269,14 +269,14 @@ export async function showModelModal(model, modelType) {
</div>`.trim() : '';
const creatorInfoAction = modelWithFullData.civitai?.creator ? `
<div class="creator-info" data-username="${modelWithFullData.civitai.creator.username}" data-action="view-creator" title="${translate('modals.model.actions.viewCreatorProfile', {}, 'View Creator Profile')}">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
${modelWithFullData.civitai.creator.image ?
`<div class="creator-avatar">
<img src="${modelWithFullData.civitai.creator.image}" alt="${modelWithFullData.civitai.creator.username}" onerror="this.onerror=null; this.src='/loras_static/icons/user-placeholder.png';">
</div>` :
`<div class="creator-avatar creator-placeholder">
</div>` :
`<div class="creator-avatar creator-placeholder">
<i class="fas fa-user"></i>
</div>`
}
}
<span class="creator-username">${modelWithFullData.civitai.creator.username}</span>
</div>`.trim() : '';
const creatorActionItems = [];
@@ -310,10 +310,10 @@ export async function showModelModal(model, modelType) {
const hasUpdateAvailable = Boolean(modelWithFullData.update_available);
const updateAvailabilityState = { hasUpdateAvailable };
const updateBadgeTooltip = translate('modelCard.badges.updateAvailable', {}, 'Update available');
// Prepare LoRA specific data with complete civitai data
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
const escapedWords = (modelType === 'loras' || modelType === 'embeddings') && modelWithFullData.civitai?.trainedWords?.length ?
modelWithFullData.civitai.trainedWords : [];
// Generate model type specific content
let typeSpecificContent;
@@ -343,7 +343,7 @@ export async function showModelModal(model, modelType) {
${versionsTabBadge}
</button>`.trim();
const tabsContent = modelType === 'loras' ?
const tabsContent = modelType === 'loras' ?
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton}
@@ -351,12 +351,12 @@ export async function showModelModal(model, modelType) {
`<button class="tab-btn active" data-tab="showcase">${examplesText}</button>
<button class="tab-btn" data-tab="description">${descriptionText}</button>
${versionsTabButton}`;
const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...');
const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...');
const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...');
const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...');
const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...');
const civitaiModelId = modelWithFullData.civitai?.modelId || '';
const civitaiVersionId = modelWithFullData.civitai?.id || '';
@@ -373,7 +373,7 @@ export async function showModelModal(model, modelType) {
</button>
</div>`.trim();
const tabPanesContent = modelType === 'loras' ?
const tabPanesContent = modelType === 'loras' ?
`<div id="showcase-tab" class="tab-pane active">
<div class="example-images-loading">
<i class="fas fa-spinner fa-spin"></i> ${loadingExampleImagesText}
@@ -518,7 +518,7 @@ export async function showModelModal(model, modelType) {
</div>
</div>
`;
function updateVersionsTabBadge(hasUpdate) {
const modalElement = document.getElementById(modalId);
if (!modalElement) return;
@@ -594,10 +594,10 @@ export async function showModelModal(model, modelType) {
updateVersionsTabBadge(hasUpdate);
updateCardUpdateAvailability(hasUpdate);
}
let showcaseCleanup;
const onCloseCallback = function() {
const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) {
@@ -610,7 +610,7 @@ export async function showModelModal(model, modelType) {
}
cleanupNavigationShortcuts();
};
modalManager.showModal(modalId, content, null, onCloseCallback);
const activeModalElement = document.getElementById(modalId);
if (activeModalElement) {
@@ -643,17 +643,17 @@ export async function showModelModal(model, modelType) {
setupEventHandlers(modelWithFullData.file_path, modelType);
setupNavigationShortcuts(modelType);
updateNavigationControls();
// LoRA specific setup
if (modelType === 'loras' || modelType === 'embeddings') {
setupTriggerWordsEditMode();
if (modelType == 'loras') {
// Load recipes for this LoRA
loadRecipesForLora(modelWithFullData.model_name, modelWithFullData.sha256);
}
}
// Load example images asynchronously - merge regular and custom images
const regularImages = modelWithFullData.civitai?.images || [];
const customImages = modelWithFullData.civitai?.customImages || [];
@@ -707,17 +707,17 @@ function detachModalHandlers(modalId) {
*/
function setupEventHandlers(filePath, modelType) {
const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) {
const target = event.target.closest('[data-action]');
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'close-modal':
modalManager.closeModal('modelModal');
@@ -748,10 +748,10 @@ function setupEventHandlers(filePath, modelType) {
break;
}
}
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick;
}
@@ -763,15 +763,15 @@ function setupEventHandlers(filePath, modelType) {
*/
function setupEditableFields(filePath, modelType) {
const editableFields = document.querySelectorAll('.editable-field [contenteditable]');
editableFields.forEach(field => {
field.addEventListener('focus', function() {
field.addEventListener('focus', function () {
if (this.textContent === 'Add your notes here...') {
this.textContent = '';
}
});
field.addEventListener('blur', function() {
field.addEventListener('blur', function () {
if (this.textContent.trim() === '') {
if (this.classList.contains('notes-content')) {
this.textContent = 'Add your notes here...';
@@ -783,7 +783,7 @@ function setupEditableFields(filePath, modelType) {
// Add keydown event listeners for notes
const notesContent = document.querySelector('.notes-content');
if (notesContent) {
notesContent.addEventListener('keydown', async function(e) {
notesContent.addEventListener('keydown', async function (e) {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Allow shift+enter for new line
@@ -810,7 +810,7 @@ function setupLoraSpecificFields(filePath) {
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
presetSelector.addEventListener('change', function() {
presetSelector.addEventListener('change', function () {
const selected = this.value;
if (selected) {
presetValue.style.display = 'inline-block';
@@ -828,10 +828,10 @@ function setupLoraSpecificFields(filePath) {
}
});
addPresetBtn.addEventListener('click', async function() {
addPresetBtn.addEventListener('click', async function () {
const key = presetSelector.value;
const value = presetValue.value;
if (!key || !value) return;
const currentPath = resolveFilePath();
@@ -839,21 +839,21 @@ function setupLoraSpecificFields(filePath) {
const loraCard = document.querySelector(`.model-card[data-filepath="${currentPath}"]`) ||
document.querySelector(`.model-card[data-filepath="${filePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
currentPresets[key] = parseFloat(value);
const newPresetsJson = JSON.stringify(currentPresets);
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
presetTags.innerHTML = renderPresetTags(currentPresets);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
});
// Add keydown event for preset value
presetValue.addEventListener('keydown', function(e) {
presetValue.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
addPresetBtn.click();

View File

@@ -6,7 +6,7 @@
import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute } from './utils.js';
import { escapeAttribute, escapeHtml } from './utils.js';
/**
* Fetch trained words for a model
@@ -17,7 +17,7 @@ async function fetchTrainedWords(filePath) {
try {
const response = await fetch(`/api/lm/trained-words?file_path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.success) {
return {
trainedWords: data.trained_words || [], // Returns array of [word, frequency] pairs
@@ -43,11 +43,11 @@ async function fetchTrainedWords(filePath) {
function createSuggestionDropdown(trainedWords, classTokens, existingWords = []) {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
// Create header
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
// No suggestions case
if ((!trainedWords || trainedWords.length === 0) && !classTokens) {
header.innerHTML = `<span>${translate('modals.model.triggerWords.suggestions.noSuggestions')}</span>`;
@@ -55,12 +55,12 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
return dropdown;
}
// Sort trained words by frequency (highest first) if available
if (trainedWords && trainedWords.length > 0) {
trainedWords.sort((a, b) => b[1] - a[1]);
}
// Add class tokens section if available
if (classTokens) {
// Add class tokens header
@@ -71,45 +71,47 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
<small>${translate('modals.model.triggerWords.suggestions.classTokenDescription')}</small>
`;
dropdown.appendChild(classTokensHeader);
// Add class tokens container
const classTokensContainer = document.createElement('div');
classTokensContainer.className = 'class-tokens-container';
// Create a special item for the class token
const tokenItem = document.createElement('div');
tokenItem.className = `metadata-suggestion-item class-token-item ${existingWords.includes(classTokens) ? 'already-added' : ''}`;
tokenItem.title = `${translate('modals.model.triggerWords.suggestions.classToken')}: ${classTokens}`;
const escapedToken = escapeHtml(classTokens);
tokenItem.innerHTML = `
<span class="metadata-suggestion-text">${classTokens}</span>
<span class="metadata-suggestion-text">${escapedToken}</span>
<div class="metadata-suggestion-meta">
<span class="token-badge">${translate('modals.model.triggerWords.suggestions.classToken')}</span>
${existingWords.includes(classTokens) ?
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
${existingWords.includes(classTokens) ?
`<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
// Add click handler if not already added
if (!existingWords.includes(classTokens)) {
tokenItem.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(classTokens);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = classTokens;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
classTokensContainer.appendChild(tokenItem);
dropdown.appendChild(classTokensContainer);
// Add separator if we also have trained words
if (trainedWords && trainedWords.length > 0) {
const separator = document.createElement('div');
@@ -117,7 +119,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
dropdown.appendChild(separator);
}
}
// Add trained words header if we have any
if (trainedWords && trainedWords.length > 0) {
header.innerHTML = `
@@ -125,52 +127,54 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
<small>${translate('modals.model.triggerWords.suggestions.wordsFound', { count: trainedWords.length })}</small>
`;
dropdown.appendChild(header);
// Create tag container for trained words
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
// Add each trained word as a tag
trainedWords.forEach(([word, frequency]) => {
const isAdded = existingWords.includes(word);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = word; // Show full word on hover if truncated
const escapedWord = escapeHtml(word);
item.innerHTML = `
<span class="metadata-suggestion-text">${word}</span>
<span class="metadata-suggestion-text">${escapedWord}</span>
<div class="metadata-suggestion-meta">
<span class="trained-word-freq">${frequency}</span>
${isAdded ? `<span class="added-indicator"><i class="fas fa-check"></i></span>` : ''}
</div>
`;
if (!isAdded) {
item.addEventListener('click', () => {
// Automatically add this word
addNewTriggerWord(word);
// Also populate the input field for potential editing
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus on the input
if (input) input.focus();
// Update dropdown without removing it
updateTrainedWordsDropdown();
});
}
container.appendChild(item);
});
dropdown.appendChild(container);
} else if (!classTokens) {
// If we have neither class tokens nor trained words
dropdown.innerHTML += `<div class="no-suggestions">${translate('modals.model.triggerWords.suggestions.noTrainedWords')}</div>`;
}
return dropdown;
}
@@ -204,7 +208,7 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
`;
return `
<div class="info-item full-width trigger-words">
<div class="trigger-words-header">
@@ -215,9 +219,12 @@ export function renderTriggerWords(words, filePath) {
</div>
<div class="trigger-words-content">
<div class="trigger-words-tags">
${words.map(word => `
<div class="trigger-word-tag" data-word="${word}" onclick="copyTriggerWord('${word}')" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${word}</span>
${words.map(word => {
const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
@@ -225,7 +232,7 @@ export function renderTriggerWords(words, filePath) {
<i class="fas fa-times"></i>
</button>
</div>
`).join('')}
`}).join('')}
</div>
</div>
<div class="metadata-edit-controls" style="display:none;">
@@ -250,68 +257,68 @@ export function setupTriggerWordsEditMode() {
let isTrainedWordsLoaded = false;
// Store original trigger words for restoring on cancel
let originalTriggerWords = [];
const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return;
editBtn.addEventListener('click', async function() {
editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
const filePath = this.dataset.filePath;
// Toggle edit mode UI elements
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const editControls = triggerWordsSection.querySelector('.metadata-edit-controls');
const addForm = triggerWordsSection.querySelector('.metadata-add-form');
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
if (isEditMode) {
this.innerHTML = '<i class="fas fa-times"></i>'; // Change to cancel icon
this.title = translate('modals.model.triggerWords.cancel');
// Store original trigger words for potential restoration
originalTriggerWords = Array.from(triggerWordTags).map(tag => tag.dataset.word);
// Show edit controls and input form
editControls.style.display = 'flex';
addForm.style.display = 'flex';
// If we have no trigger words yet, hide the "No trigger word needed" text
// and show the empty tags container
if (noTriggerWords) {
noTriggerWords.style.display = 'none';
if (tagsContainer) tagsContainer.style.display = 'flex';
}
// Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => {
tag.onclick = null;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
if (copyIcon) copyIcon.style.display = 'none';
if (deleteBtn) {
deleteBtn.style.display = 'block';
// Re-attach event listener to ensure it works every time
// First remove any existing listeners to avoid duplication
deleteBtn.removeEventListener('click', deleteTriggerWord);
deleteBtn.addEventListener('click', deleteTriggerWord);
}
});
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'metadata-loading';
loadingIndicator.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${translate('modals.model.triggerWords.suggestions.loading')}`;
addForm.appendChild(loadingIndicator);
// Get currently added trigger words
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Asynchronously load trained words if not already loaded
if (!isTrainedWordsLoaded) {
const result = await fetchTrainedWords(filePath);
@@ -319,25 +326,25 @@ export function setupTriggerWordsEditMode() {
classTokensValue = result.classTokens;
isTrainedWordsLoaded = true;
}
// Remove loading indicator
loadingIndicator.remove();
// Create and display suggestion dropdown
const dropdown = createSuggestionDropdown(trainedWordsList, classTokensValue, existingWords);
addForm.appendChild(dropdown);
// Focus the input
addForm.querySelector('input').focus();
} else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit');
// Hide edit controls and input form
editControls.style.display = 'none';
addForm.style.display = 'none';
// Check if we're exiting edit mode due to "Save" or "Cancel"
if (!this.dataset.skipRestore) {
// If canceling, restore original trigger words
@@ -348,7 +355,7 @@ export function setupTriggerWordsEditMode() {
// Reset the skip restore flag
delete this.dataset.skipRestore;
}
// If we have no trigger words, show the "No trigger word needed" text
// and hide the empty tags container
const currentTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
@@ -356,19 +363,19 @@ export function setupTriggerWordsEditMode() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
}
});
// Set up input for adding trigger words
const triggerWordInput = document.querySelector('.metadata-input');
if (triggerWordInput) {
// Add keydown event to input
triggerWordInput.addEventListener('keydown', function(e) {
triggerWordInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
addNewTriggerWord(this.value);
@@ -376,13 +383,13 @@ export function setupTriggerWordsEditMode() {
}
});
}
// Set up save button
const saveBtn = document.querySelector('.metadata-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', saveTriggerWords);
}
// Set up delete buttons
document.querySelectorAll('.metadata-delete-btn').forEach(btn => {
// Remove any existing listeners to avoid duplication
@@ -399,7 +406,7 @@ function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
tag.remove();
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -410,15 +417,15 @@ function deleteTriggerWord(e) {
*/
function resetTriggerWordsUIState(section) {
const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality
tag.onclick = () => copyTriggerWord(word);
tag.onclick = () => copyTriggerWord(tag.dataset.word);
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
if (deleteBtn) deleteBtn.style.display = 'none';
@@ -433,30 +440,32 @@ function resetTriggerWordsUIState(section) {
function restoreOriginalTriggerWords(section, originalWords) {
const tagsContainer = section.querySelector('.trigger-words-tags');
const noTriggerWords = section.querySelector('.no-trigger-words');
if (!tagsContainer) return;
// Clear current tags
tagsContainer.innerHTML = '';
if (originalWords.length === 0) {
if (noTriggerWords) noTriggerWords.style.display = '';
tagsContainer.style.display = 'none';
return;
}
// Hide "no trigger words" message
if (noTriggerWords) noTriggerWords.style.display = 'none';
tagsContainer.style.display = 'flex';
// Recreate original tags
originalWords.forEach(word => {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(word);
tag.onclick = () => copyTriggerWord(tag.dataset.word);
const escapedWord = escapeHtml(word);
tag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
@@ -475,10 +484,10 @@ function restoreOriginalTriggerWords(section, originalWords) {
function addNewTriggerWord(word) {
word = word.trim();
if (!word) return;
const triggerWordsSection = document.querySelector('.trigger-words');
let tagsContainer = document.querySelector('.trigger-words-tags');
// Ensure tags container exists and is visible
if (tagsContainer) {
tagsContainer.style.display = 'flex';
@@ -491,41 +500,43 @@ function addNewTriggerWord(word) {
contentDiv.appendChild(tagsContainer);
}
}
if (!tagsContainer) return;
// Hide "no trigger words" message if it exists
const noTriggerWordsMsg = triggerWordsSection.querySelector('.no-trigger-words');
if (noTriggerWordsMsg) {
noTriggerWordsMsg.style.display = 'none';
}
// Validation: Check length
if (word.split(/\s+/).length > 100) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= 30) {
showToast('toast.triggerWords.tooMany', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
const escapedWord = escapeHtml(word);
newTag.innerHTML = `
<span class="trigger-word-content">${word}</span>
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
@@ -533,13 +544,13 @@ function addNewTriggerWord(word) {
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -550,19 +561,19 @@ function addNewTriggerWord(word) {
function updateTrainedWordsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current trigger words
const currentTags = document.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const wordText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingWords.includes(wordText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
@@ -572,27 +583,27 @@ function updateTrainedWordsDropdown() {
indicator.innerHTML = '<i class="fas fa-check"></i>';
meta.appendChild(indicator);
}
// Remove click event
item.onclick = null;
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item.onclick) {
item.onclick = () => {
const word = item.querySelector('.metadata-suggestion-text').textContent;
addNewTriggerWord(word);
// Also populate the input field
const input = document.querySelector('.metadata-input');
if (input) input.value = word;
// Focus the input
if (input) input.focus();
};
@@ -610,19 +621,19 @@ async function saveTriggerWords() {
const triggerWordsSection = editBtn.closest('.trigger-words');
const triggerWordTags = triggerWordsSection.querySelectorAll('.trigger-word-tag');
const words = Array.from(triggerWordTags).map(tag => tag.dataset.word);
try {
// Special format for updating nested civitai.trainedWords
await getModelApiClient().saveModelMetadata(filePath, {
civitai: { trainedWords: words }
});
// Set flag to skip restoring original words when exiting edit mode
editBtn.dataset.skipRestore = "true";
// Exit edit mode without restoring original trigger words
editBtn.click();
// If we saved an empty array and there's a no-trigger-words element, show it
const noTriggerWords = triggerWordsSection.querySelector('.no-trigger-words');
const tagsContainer = triggerWordsSection.querySelector('.trigger-words-tags');
@@ -630,7 +641,7 @@ async function saveTriggerWords() {
noTriggerWords.style.display = '';
if (tagsContainer) tagsContainer.style.display = 'none';
}
showToast('toast.triggerWords.updateSuccess', {}, 'success');
} catch (error) {
console.error('Error saving trigger words:', error);
@@ -642,7 +653,7 @@ async function saveTriggerWords() {
* Copy a trigger word to clipboard
* @param {string} word - Word to copy
*/
window.copyTriggerWord = async function(word) {
window.copyTriggerWord = async function (word) {
try {
await copyToClipboard(word, 'Trigger word copied');
} catch (err) {

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
UTILS_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(),
}));
describe("TriggerWords HTML Escaping", () => {
let renderTriggerWords;
beforeEach(async () => {
document.body.innerHTML = '';
const module = await import(TRIGGER_WORDS_MODULE);
renderTriggerWords = module.renderTriggerWords;
});
it("escapes HTML tags in trigger words rendering", () => {
const words = ["<style>guangying</style>", "fym <artist>"];
const html = renderTriggerWords(words, "test.safetensors");
expect(html).toContain("&lt;style&gt;guangying&lt;/style&gt;");
expect(html).toContain("fym &lt;artist&gt;");
expect(html).not.toContain("<style>guangying</style>");
});
it("uses dataset for copyTriggerWord to safely handle special characters", () => {
const words = ["word'with'quotes", "<tag>"];
const html = renderTriggerWords(words, "test.safetensors");
// Check for dataset-word attribute
expect(html).toContain('data-word="word&#39;with&#39;quotes"');
expect(html).toContain('data-word="&lt;tag&gt;"');
// Check for the onclick handler
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"');
});
});