Harden synced expression portrait URLs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '"')
|
||||
.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 = $('<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() {
|
||||
$(`#${PANEL_ID}`).remove();
|
||||
}
|
||||
@@ -120,42 +150,28 @@ export function renderAlternatePresentCharacters({ useCommittedFallback = true }
|
||||
|
||||
const title = i18n.getTranslation('template.trackerEditorModal.tabs.presentCharacters') || 'Present Characters';
|
||||
|
||||
let html = `
|
||||
<div class="rpg-alt-present-characters__header">
|
||||
<div class="rpg-alt-present-characters__title">
|
||||
<i class="fa-solid fa-users" aria-hidden="true"></i>
|
||||
<span>${escapeHtml(title)}</span>
|
||||
</div>
|
||||
<div class="rpg-alt-present-characters__count">${presentCharacters.length}</div>
|
||||
</div>
|
||||
<div class="rpg-alt-present-characters__scroll">
|
||||
<div class="rpg-alt-present-characters__track">
|
||||
`;
|
||||
const $panel = ensureAlternatePresentCharactersPanel();
|
||||
const $header = $('<div class="rpg-alt-present-characters__header"></div>');
|
||||
const $headerTitle = $('<div class="rpg-alt-present-characters__title"></div>');
|
||||
const $scroll = $('<div class="rpg-alt-present-characters__scroll"></div>');
|
||||
const $track = $('<div class="rpg-alt-present-characters__track"></div>');
|
||||
|
||||
$headerTitle.append(
|
||||
$('<i class="fa-solid fa-users" aria-hidden="true"></i>'),
|
||||
$('<span></span>').text(title)
|
||||
);
|
||||
|
||||
$header.append(
|
||||
$headerTitle,
|
||||
$('<div class="rpg-alt-present-characters__count"></div>').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 += `
|
||||
<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>
|
||||
`;
|
||||
$track.append(createAlternatePresentCharacterCard(character));
|
||||
}
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$scroll.append($track);
|
||||
|
||||
const $panel = ensureAlternatePresentCharactersPanel();
|
||||
$panel.html(html).show();
|
||||
$panel.empty().append($header, $scroll).show();
|
||||
syncAlternatePresentCharactersTheme();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user