diff --git a/src/systems/integration/expressionSync.js b/src/systems/integration/expressionSync.js index acf7a60..a700687 100644 --- a/src/systems/integration/expressionSync.js +++ b/src/systems/integration/expressionSync.js @@ -15,6 +15,7 @@ import { removeSyncedExpressionPortrait } from '../../core/state.js'; import { saveChatData } from '../../core/persistence.js'; +import { isSafeImageSrc, normalizeImageSrc, resolveImageUrl } from '../../utils/imageUrls.js'; import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js'; let expressionContainerObserver = null; @@ -33,20 +34,11 @@ function normalizeName(name) { } function normalizeExpressionSrc(src) { - return String(src || '').trim(); + return normalizeImageSrc(src); } function resolveExpressionUrl(src) { - const normalized = normalizeExpressionSrc(src); - if (!normalized) { - return null; - } - - try { - return new URL(normalized, window.location.href); - } catch { - return null; - } + return resolveImageUrl(src); } function isDocumentLikeUrl(src) { @@ -76,6 +68,10 @@ function isUsableExpressionSrc(src) { return false; } + if (!isSafeImageSrc(normalized)) { + return false; + } + return true; } diff --git a/src/systems/ui/alternatePresentCharacters.js b/src/systems/ui/alternatePresentCharacters.js index 34969ef..e0a28d0 100644 --- a/src/systems/ui/alternatePresentCharacters.js +++ b/src/systems/ui/alternatePresentCharacters.js @@ -1,5 +1,6 @@ import { extensionSettings } from '../../core/state.js'; import { i18n } from '../../core/i18n.js'; +import { getSafeImageSrc } from '../../utils/imageUrls.js'; import { getExpressionPortraitForCharacter } from '../integration/expressionSync.js'; import { getPresentCharactersTrackerData, @@ -34,15 +35,6 @@ function ensureAlternatePresentCharactersPanel() { return $panel; } -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - function hexToRgba(hex, opacity = 100) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); @@ -51,6 +43,44 @@ function hexToRgba(hex, opacity = 100) { return `rgba(${r}, ${g}, ${b}, ${a})`; } +function handlePortraitLoadError() { + this.style.opacity = '0.5'; + $(this).off('error', handlePortraitLoadError); +} + +function createAlternatePresentCharacterCard(character) { + const rawPortrait = (extensionSettings.syncExpressionsToPresentCharacters + ? getExpressionPortraitForCharacter(character.name) + : null) || resolvePresentCharacterPortrait(character.name); + const portrait = getSafeImageSrc(rawPortrait); + const name = String(character.name || ''); + + const $card = $('
') + .attr('data-character-name', name) + .attr('title', name); + + const $portrait = $('
'); + const $image = $('') + .attr({ + alt: name, + loading: 'lazy' + }) + .on('error', handlePortraitLoadError); + + if (portrait) { + $image.attr('src', portrait); + } + + const $meta = $('
'); + const $name = $('
').text(name); + + $portrait.append($image); + $meta.append($name); + $card.append($portrait, $meta); + + return $card; +} + export function removeAlternatePresentCharactersPanel() { $(`#${PANEL_ID}`).remove(); } @@ -120,42 +150,28 @@ export function renderAlternatePresentCharacters({ useCommittedFallback = true } const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters'; - let html = ` -
-
- - ${escapeHtml(title)} -
-
${presentCharacters.length}
-
-
-
- `; + const $panel = ensureAlternatePresentCharactersPanel(); + const $header = $('
'); + const $headerTitle = $('
'); + const $scroll = $('
'); + const $track = $('
'); + + $headerTitle.append( + $(''), + $('').text(title) + ); + + $header.append( + $headerTitle, + $('
').text(String(presentCharacters.length)) + ); for (const character of presentCharacters) { - const portrait = (extensionSettings.syncExpressionsToPresentCharacters - ? getExpressionPortraitForCharacter(character.name) - : null) || resolvePresentCharacterPortrait(character.name); - const name = escapeHtml(character.name || ''); - - html += ` -
-
- ${name} -
-
-
${name}
-
-
- `; + $track.append(createAlternatePresentCharacterCard(character)); } - html += ` -
-
- `; + $scroll.append($track); - const $panel = ensureAlternatePresentCharactersPanel(); - $panel.html(html).show(); + $panel.empty().append($header, $scroll).show(); syncAlternatePresentCharactersTheme(); } diff --git a/src/utils/imageUrls.js b/src/utils/imageUrls.js new file mode 100644 index 0000000..df0a0aa --- /dev/null +++ b/src/utils/imageUrls.js @@ -0,0 +1,53 @@ +/** + * Image URL Utilities Module + * Centralizes validation for image sources captured from DOM or settings. + */ + +const DEFAULT_IMAGE_BASE_URL = typeof window !== 'undefined' + ? window.location.href + : 'http://localhost/'; + +export function normalizeImageSrc(src) { + return String(src ?? '').trim(); +} + +export function resolveImageUrl(src, baseUrl = DEFAULT_IMAGE_BASE_URL) { + const normalized = normalizeImageSrc(src); + if (!normalized) { + return null; + } + + try { + return new URL(normalized, baseUrl); + } catch { + return null; + } +} + +export function isSafeImageSrc(src) { + const normalized = normalizeImageSrc(src); + if (!normalized) { + return false; + } + + const candidate = resolveImageUrl(normalized); + if (!candidate) { + return false; + } + + const protocol = candidate.protocol.toLowerCase(); + if (protocol === 'http:' || protocol === 'https:' || protocol === 'blob:') { + return true; + } + + if (protocol === 'data:') { + return normalized.toLowerCase().startsWith('data:image/'); + } + + return false; +} + +export function getSafeImageSrc(src) { + const normalized = normalizeImageSrc(src); + return isSafeImageSrc(normalized) ? normalized : null; +}