diff --git a/web/resolver/actions/resolve_download_methods.js b/web/resolver/actions/resolve_download_methods.js
index 322de1c..c4a84ef 100644
--- a/web/resolver/actions/resolve_download_methods.js
+++ b/web/resolver/actions/resolve_download_methods.js
@@ -3,6 +3,7 @@ import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { createModuleLogger } from "../../log_system/log_funcs.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
+import { getModelCardUrl } from "../utils/url_utils.js";
const log = createModuleLogger('resolve_download_methods');
@@ -293,7 +294,7 @@ export const resolveDownloadMethods = {
url,
filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename,
directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints',
- model_url: this.getModelCardUrl(rawModelUrl) || rawModelUrl,
+ model_url: getModelCardUrl(rawModelUrl) || rawModelUrl,
name: result.name || result.repo_id || result.repo || result.filename || filename,
size: result.size,
type: result.type || missing.category
@@ -2571,7 +2572,7 @@ export const resolveDownloadMethods = {
downloadUrl: popular.url,
downloadFilename: popularFilename,
category: popular.directory || missingCategory,
- openUrl: this.getModelCardUrl(popular.url),
+ openUrl: getModelCardUrl(popular.url),
searchedAt: this.getSearchResultTimestamp(popular)
});
}
@@ -2589,14 +2590,14 @@ export const resolveDownloadMethods = {
downloadUrl: modelListResult.url,
downloadFilename: modelListResult.filename,
category: modelListResult.directory || missingCategory,
- openUrl: this.getModelCardUrl(modelListResult.url),
+ openUrl: getModelCardUrl(modelListResult.url),
searchedAt: this.getSearchResultTimestamp(modelListResult)
});
}
if (hfResult && hfResult.url) {
const hfRepo = hfResult.repo_id || hfResult.repo || '';
- const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url);
+ const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : getModelCardUrl(hfResult.url);
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
addRow({
sourceKey: 'huggingface',
@@ -2670,7 +2671,7 @@ export const resolveDownloadMethods = {
downloadUrl: loraManagerArchiveResult.download_url || '',
downloadFilename: archiveFilename,
category: archiveCategory,
- openUrl: loraManagerArchiveResult.url || this.getModelCardUrl(loraManagerArchiveResult.download_url),
+ openUrl: loraManagerArchiveResult.url || getModelCardUrl(loraManagerArchiveResult.download_url),
searchedAt: this.getSearchResultTimestamp(loraManagerArchiveResult),
detailsContext: {
...loraManagerArchiveResult,
diff --git a/web/resolver/search/search_panel.js b/web/resolver/search/search_panel.js
index d85c67d..ca727bc 100644
--- a/web/resolver/search/search_panel.js
+++ b/web/resolver/search/search_panel.js
@@ -2,6 +2,7 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
+import { getModelCardUrl } from "../utils/url_utils.js";
export const searchPanelMethods = {
/**
* Build stable cache key for a missing model entry
@@ -1701,7 +1702,7 @@ export const searchPanelMethods = {
const rawModelUrl = downloadSource.model_url
|| downloadSource.workflow_model_url
|| downloadSource.url;
- const modelUrl = this.getModelCardUrl(rawModelUrl) || rawModelUrl;
+ const modelUrl = getModelCardUrl(rawModelUrl) || rawModelUrl;
const versionName = downloadSource.version_name || missing.civitai_info?.version_name || '';
const modelParts = this.getModelVersionParts(
downloadSource.name || missing.civitai_info?.model_name || '',
diff --git a/web/resolver/shell/workflow_update_methods.js b/web/resolver/shell/workflow_update_methods.js
index 5fe9af4..8f8fa75 100644
--- a/web/resolver/shell/workflow_update_methods.js
+++ b/web/resolver/shell/workflow_update_methods.js
@@ -2,6 +2,7 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
+import { getModelCardUrl } from "../utils/url_utils.js";
export const workflowUpdateMethods = {
async refreshComfyModelCatalogAfterApply(workflow = null, resolutions = []) {
if (!this.isAutoRefreshComfyModelsAfterApplyEnabled()) {
@@ -270,38 +271,7 @@ export const workflowUpdateMethods = {
* CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123
*/
getModelCardUrl(downloadUrl) {
- if (!downloadUrl) return null;
-
- try {
- // HuggingFace URLs
- if (downloadUrl.includes('huggingface.co')) {
- const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
- if (fileMatch) {
- const repo = fileMatch[1];
- const revision = fileMatch[2];
- const filePath = fileMatch[3].split(/[?#]/)[0];
- return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
- }
-
- const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
- if (match) {
- return `https://huggingface.co/${match[1]}`;
- }
- }
-
- // CivitAI URLs
- if (downloadUrl.includes('civitai.com')) {
- // Format: /api/download/models/123456 or /models/123456/...
- const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
- if (modelIdMatch) {
- return `https://civitai.com/models/${modelIdMatch[1]}`;
- }
- }
- } catch (e) {
- console.error('Error parsing model card URL:', e);
- }
-
- return null;
+ return getModelCardUrl(downloadUrl);
},
/**
diff --git a/web/resolver/utils/html_utils.js b/web/resolver/utils/html_utils.js
new file mode 100644
index 0000000..11c513c
--- /dev/null
+++ b/web/resolver/utils/html_utils.js
@@ -0,0 +1,121 @@
+/**
+ * HTML Utilities for Model Resolver
+ */
+
+/**
+ * Escapes special characters for HTML to prevent XSS.
+ * @param {any} value
+ * @returns {string} Escaped string
+ */
+export function escapeHtml(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/**
+ * Escapes a value to be safely embedded inside inline JavaScript.
+ * @param {any} value
+ * @returns {string} Escaped string
+ */
+export function escapeJsString(value) {
+ return escapeHtml(JSON.stringify(String(value ?? '')));
+}
+
+/**
+ * Sanitizes HTML content for display, permitting only safe tags and attributes.
+ * @param {string} html
+ * @returns {string} Sanitized HTML
+ */
+export function sanitizeDescriptionHtml(html) {
+ const raw = String(html ?? '').trim();
+ if (!raw) return '';
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(`
${raw}
`, 'text/html');
+ const root = doc.body.firstElementChild;
+ if (!root) return escapeHtml(raw);
+
+ const allowedTags = new Set([
+ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
+ 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'a', 'span'
+ ]);
+ const allowedStyles = new Set(['color']);
+
+ const sanitizeNode = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return;
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ node.remove();
+ return;
+ }
+
+ const tag = node.tagName.toLowerCase();
+ if (!allowedTags.has(tag)) {
+ const parent = node.parentNode;
+ if (!parent) {
+ node.remove();
+ return;
+ }
+
+ while (node.firstChild) {
+ parent.insertBefore(node.firstChild, node);
+ }
+ parent.removeChild(node);
+ return;
+ }
+
+ const attrs = Array.from(node.attributes);
+ for (const attr of attrs) {
+ const name = attr.name.toLowerCase();
+ if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
+ node.removeAttribute(attr.name);
+ continue;
+ }
+
+ if (name === 'href' && tag === 'a') {
+ const href = node.getAttribute('href') || '';
+ if (!/^https?:\/\//i.test(href)) {
+ node.removeAttribute('href');
+ } else {
+ node.setAttribute('target', '_blank');
+ node.setAttribute('rel', 'noopener noreferrer');
+ }
+ continue;
+ }
+
+ if (name === 'style') {
+ const safeStyles = [];
+ const styleValue = node.getAttribute('style') || '';
+ for (const part of styleValue.split(';')) {
+ const [prop, value] = part.split(':').map(v => v?.trim());
+ if (!prop || !value) continue;
+ if (allowedStyles.has(prop.toLowerCase())) {
+ safeStyles.push(`${prop}: ${value}`);
+ }
+ }
+ if (safeStyles.length) {
+ node.setAttribute('style', safeStyles.join('; '));
+ } else {
+ node.removeAttribute('style');
+ }
+ continue;
+ }
+
+ if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
+ node.removeAttribute(attr.name);
+ }
+ }
+
+ Array.from(node.childNodes).forEach(child => sanitizeNode(child));
+ };
+
+ Array.from(root.childNodes).forEach(child => sanitizeNode(child));
+ return root.innerHTML;
+}
diff --git a/web/resolver/utils/url_utils.js b/web/resolver/utils/url_utils.js
new file mode 100644
index 0000000..d8e2958
--- /dev/null
+++ b/web/resolver/utils/url_utils.js
@@ -0,0 +1,44 @@
+/**
+ * URL Utilities for Model Resolver
+ */
+
+/**
+ * Parsuje adres URL pobierania i zwraca adres karty modelu (Model Card).
+ * Obsługuje platformy HuggingFace oraz CivitAI.
+ * @param {string} downloadUrl
+ * @returns {string|null} URL karty modelu lub null w przypadku niepowodzenia
+ */
+export function getModelCardUrl(downloadUrl) {
+ if (!downloadUrl) return null;
+
+ try {
+ // HuggingFace URLs
+ if (downloadUrl.includes('huggingface.co')) {
+ const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
+ if (fileMatch) {
+ const repo = fileMatch[1];
+ const revision = fileMatch[2];
+ const filePath = fileMatch[3].split(/[?#]/)[0];
+ return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
+ }
+
+ const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
+ if (match) {
+ return `https://huggingface.co/${match[1]}`;
+ }
+ }
+
+ // CivitAI URLs
+ if (downloadUrl.includes('civitai.com')) {
+ // Format: /api/download/models/123456 or /models/123456/...
+ const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
+ if (modelIdMatch) {
+ return `https://civitai.com/models/${modelIdMatch[1]}`;
+ }
+ }
+ } catch (e) {
+ console.error('Error parsing model card URL:', e);
+ }
+
+ return null;
+}
diff --git a/web/resolver/views/model_info_methods.js b/web/resolver/views/model_info_methods.js
index 2462c4b..ca07346 100644
--- a/web/resolver/views/model_info_methods.js
+++ b/web/resolver/views/model_info_methods.js
@@ -2,6 +2,8 @@ import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
+import { escapeHtml, escapeJsString, sanitizeDescriptionHtml } from "../utils/html_utils.js";
+import { getModelCardUrl } from "../utils/url_utils.js";
export const modelInfoMethods = {
/**
* Handle click outside context menu to hide it
@@ -291,7 +293,7 @@ export const modelInfoMethods = {
const normalizedUrl = this.normalizeContextMenuSourceUrl(candidate, candidateKey);
if (!normalizedUrl) continue;
- const cardUrl = this.getModelCardUrl?.(normalizedUrl) || normalizedUrl;
+ const cardUrl = getModelCardUrl(normalizedUrl) || normalizedUrl;
const sourceKey = this.getContextMenuSourceKeyFromUrl(cardUrl)
|| this.getContextMenuSourceKeyFromUrl(normalizedUrl)
|| candidateKey;
@@ -1259,16 +1261,11 @@ export const modelInfoMethods = {
},
escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+ return escapeHtml(value);
},
escapeJsString(value) {
- return this.escapeHtml(JSON.stringify(String(value ?? '')));
+ return escapeJsString(value);
},
truncateText(value, maxLength = 160) {
@@ -1278,93 +1275,7 @@ export const modelInfoMethods = {
},
sanitizeDescriptionHtml(html) {
- const raw = String(html ?? '').trim();
- if (!raw) return '';
-
- const parser = new DOMParser();
- const doc = parser.parseFromString(`${raw}
`, 'text/html');
- const root = doc.body.firstElementChild;
- if (!root) return this.escapeHtml(raw);
-
- const allowedTags = new Set([
- 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
- 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'a', 'span'
- ]);
- const allowedStyles = new Set(['color']);
-
- const sanitizeNode = (node) => {
- if (node.nodeType === Node.TEXT_NODE) {
- return;
- }
-
- if (node.nodeType !== Node.ELEMENT_NODE) {
- node.remove();
- return;
- }
-
- const tag = node.tagName.toLowerCase();
- if (!allowedTags.has(tag)) {
- const parent = node.parentNode;
- if (!parent) {
- node.remove();
- return;
- }
-
- while (node.firstChild) {
- parent.insertBefore(node.firstChild, node);
- }
- parent.removeChild(node);
- return;
- }
-
- const attrs = Array.from(node.attributes);
- for (const attr of attrs) {
- const name = attr.name.toLowerCase();
- if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
- node.removeAttribute(attr.name);
- continue;
- }
-
- if (name === 'href' && tag === 'a') {
- const href = node.getAttribute('href') || '';
- if (!/^https?:\/\//i.test(href)) {
- node.removeAttribute('href');
- } else {
- node.setAttribute('target', '_blank');
- node.setAttribute('rel', 'noopener noreferrer');
- }
- continue;
- }
-
- if (name === 'style') {
- const safeStyles = [];
- const styleValue = node.getAttribute('style') || '';
- for (const part of styleValue.split(';')) {
- const [prop, value] = part.split(':').map(v => v?.trim());
- if (!prop || !value) continue;
- if (allowedStyles.has(prop.toLowerCase())) {
- safeStyles.push(`${prop}: ${value}`);
- }
- }
- if (safeStyles.length) {
- node.setAttribute('style', safeStyles.join('; '));
- } else {
- node.removeAttribute('style');
- }
- continue;
- }
-
- if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
- node.removeAttribute(attr.name);
- }
- }
-
- Array.from(node.childNodes).forEach(child => sanitizeNode(child));
- };
-
- Array.from(root.childNodes).forEach(child => sanitizeNode(child));
- return root.innerHTML;
+ return sanitizeDescriptionHtml(html);
},
normalizeTrainedWords(words) {