Harden synced expression portrait URLs
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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