From b7e7b1b295f881b2d75e59c84e51424cee800907 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 2 Jul 2026 04:18:04 +0200 Subject: [PATCH] refactor: centralize getModelCardUrl and HTML/URL utilities in resolver/utils --- .../actions/resolve_download_methods.js | 11 +- web/resolver/search/search_panel.js | 3 +- web/resolver/shell/workflow_update_methods.js | 34 +---- web/resolver/utils/html_utils.js | 121 ++++++++++++++++++ web/resolver/utils/url_utils.js | 44 +++++++ web/resolver/views/model_info_methods.js | 101 +-------------- 6 files changed, 181 insertions(+), 133 deletions(-) create mode 100644 web/resolver/utils/html_utils.js create mode 100644 web/resolver/utils/url_utils.js 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) {