Harden synced expression portrait URLs

This commit is contained in:
Tremendoussly
2026-03-15 15:54:46 +01:00
parent 08097e8b41
commit 4d2afafbaf
3 changed files with 117 additions and 52 deletions
+7 -11
View File
@@ -15,6 +15,7 @@ import {
removeSyncedExpressionPortrait removeSyncedExpressionPortrait
} from '../../core/state.js'; } from '../../core/state.js';
import { saveChatData } from '../../core/persistence.js'; import { saveChatData } from '../../core/persistence.js';
import { isSafeImageSrc, normalizeImageSrc, resolveImageUrl } from '../../utils/imageUrls.js';
import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js'; import { renderAlternatePresentCharacters } from '../ui/alternatePresentCharacters.js';
let expressionContainerObserver = null; let expressionContainerObserver = null;
@@ -33,20 +34,11 @@ function normalizeName(name) {
} }
function normalizeExpressionSrc(src) { function normalizeExpressionSrc(src) {
return String(src || '').trim(); return normalizeImageSrc(src);
} }
function resolveExpressionUrl(src) { function resolveExpressionUrl(src) {
const normalized = normalizeExpressionSrc(src); return resolveImageUrl(src);
if (!normalized) {
return null;
}
try {
return new URL(normalized, window.location.href);
} catch {
return null;
}
} }
function isDocumentLikeUrl(src) { function isDocumentLikeUrl(src) {
@@ -76,6 +68,10 @@ function isUsableExpressionSrc(src) {
return false; return false;
} }
if (!isSafeImageSrc(normalized)) {
return false;
}
return true; return true;
} }
+57 -41
View File
@@ -1,5 +1,6 @@
import { extensionSettings } from '../../core/state.js'; import { extensionSettings } from '../../core/state.js';
import { i18n } from '../../core/i18n.js'; import { i18n } from '../../core/i18n.js';
import { getSafeImageSrc } from '../../utils/imageUrls.js';
import { getExpressionPortraitForCharacter } from '../integration/expressionSync.js'; import { getExpressionPortraitForCharacter } from '../integration/expressionSync.js';
import { import {
getPresentCharactersTrackerData, getPresentCharactersTrackerData,
@@ -34,15 +35,6 @@ function ensureAlternatePresentCharactersPanel() {
return $panel; return $panel;
} }
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function hexToRgba(hex, opacity = 100) { function hexToRgba(hex, opacity = 100) {
const r = parseInt(hex.slice(1, 3), 16); const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16); const g = parseInt(hex.slice(3, 5), 16);
@@ -51,6 +43,44 @@ function hexToRgba(hex, opacity = 100) {
return `rgba(${r}, ${g}, ${b}, ${a})`; 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 = $('<div class="rpg-alt-present-character"></div>')
.attr('data-character-name', name)
.attr('title', name);
const $portrait = $('<div class="rpg-alt-present-character__portrait"></div>');
const $image = $('<img />')
.attr({
alt: name,
loading: 'lazy'
})
.on('error', handlePortraitLoadError);
if (portrait) {
$image.attr('src', portrait);
}
const $meta = $('<div class="rpg-alt-present-character__meta"></div>');
const $name = $('<div class="rpg-alt-present-character__name"></div>').text(name);
$portrait.append($image);
$meta.append($name);
$card.append($portrait, $meta);
return $card;
}
export function removeAlternatePresentCharactersPanel() { export function removeAlternatePresentCharactersPanel() {
$(`#${PANEL_ID}`).remove(); $(`#${PANEL_ID}`).remove();
} }
@@ -120,42 +150,28 @@ export function renderAlternatePresentCharacters({ useCommittedFallback = true }
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters'; const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
let html = ` const $panel = ensureAlternatePresentCharactersPanel();
<div class="rpg-alt-present-characters__header"> const $header = $('<div class="rpg-alt-present-characters__header"></div>');
<div class="rpg-alt-present-characters__title"> const $headerTitle = $('<div class="rpg-alt-present-characters__title"></div>');
<i class="fa-solid fa-users" aria-hidden="true"></i> const $scroll = $('<div class="rpg-alt-present-characters__scroll"></div>');
<span>${escapeHtml(title)}</span> const $track = $('<div class="rpg-alt-present-characters__track"></div>');
</div>
<div class="rpg-alt-present-characters__count">${presentCharacters.length}</div> $headerTitle.append(
</div> $('<i class="fa-solid fa-users" aria-hidden="true"></i>'),
<div class="rpg-alt-present-characters__scroll"> $('<span></span>').text(title)
<div class="rpg-alt-present-characters__track"> );
`;
$header.append(
$headerTitle,
$('<div class="rpg-alt-present-characters__count"></div>').text(String(presentCharacters.length))
);
for (const character of presentCharacters) { for (const character of presentCharacters) {
const portrait = (extensionSettings.syncExpressionsToPresentCharacters $track.append(createAlternatePresentCharacterCard(character));
? getExpressionPortraitForCharacter(character.name)
: null) || resolvePresentCharacterPortrait(character.name);
const name = escapeHtml(character.name || '');
html += `
<div class="rpg-alt-present-character" data-character-name="${name}" title="${name}">
<div class="rpg-alt-present-character__portrait">
<img src="${portrait}" alt="${name}" loading="lazy" onerror="this.style.opacity='0.5';this.onerror=null;" />
</div>
<div class="rpg-alt-present-character__meta">
<div class="rpg-alt-present-character__name">${name}</div>
</div>
</div>
`;
} }
html += ` $scroll.append($track);
</div>
</div>
`;
const $panel = ensureAlternatePresentCharactersPanel(); $panel.empty().append($header, $scroll).show();
$panel.html(html).show();
syncAlternatePresentCharactersTheme(); syncAlternatePresentCharactersTheme();
} }
+53
View File
@@ -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;
}