feat: preserve CivArchive page URLs separate from HuggingFace mirrors, fix HTML array escaping, and accept provider array payloads

Backend:
- Keep CivArchive source page URL distinct from HuggingFace download mirrors in download metadata
- Handle CivitAI hash lookup HTTP errors and unexpected JSON shapes without caching failures
- Allow network JSON requests to return arrays as well as objects

Frontend:
- Escape each item individually when interpolating arrays into HTML templates
- Prefer the selected provider's page URL over cross-provider mirrors in context menus and download targets
- Reuse source link resolution for model info display

Tests:
- Add coverage for CivArchive metadata URL isolation, HTML array escaping, provider array responses, and CivitAI hash lookup caching behavior
This commit is contained in:
Dariusz L
2026-07-20 16:16:09 +02:00
parent 60ca3a381a
commit 097be623f4
10 changed files with 311 additions and 19 deletions
+23
View File
@@ -550,6 +550,29 @@ def build_lora_manager_metadata(
source.get("platform_url"),
source_url,
)
if source_name == "civarchive":
civarchive_page_url = next(
(
str(value).strip()
for value in (
source.get("version_url"),
source.get("model_url"),
source.get("page_url"),
source.get("source_url"),
source.get("url"),
)
if value
and host_matches_domain(urlparse(str(value)).hostname, "civarchive.com")
),
"",
)
if not civarchive_page_url and model_id:
civarchive_page_url = f"https://civarchive.com/models/{model_id}"
if version_id:
civarchive_page_url += f"?modelVersionId={version_id}"
if not civarchive_page_url and sha256:
civarchive_page_url = f"https://civarchive.com/sha256/{sha256}"
source_page_url = civarchive_page_url
platform_url = _first_present(source.get("platform_url"), source.get("platformUrl"))
preview_url = _first_present(
source.get("preview_url"),
+7 -5
View File
@@ -250,8 +250,8 @@ def request_source_json(
max_attempts: int = 2,
log_name: str = "Source API",
raise_on_error: bool = False,
) -> Optional[Dict[str, Any]]:
"""Perform a GET/POST request and return JSON with retry logic for HTTP 429."""
) -> Any:
"""Perform a GET/POST request and return any parsed JSON payload or ``None``."""
response = request_source_response(
url,
method=method,
@@ -311,10 +311,12 @@ def execute_provider_json_request(
timeout: int = 20,
max_attempts: int = 2,
raise_on_error: bool = False,
) -> Optional[Dict[str, Any]]:
) -> Any:
"""
Execute an outbound JSON request to a provider (CivitAI, CivArchive, HF, etc.)
with normalized user-agent, authorization token/session headers, and 429 retry handling.
Execute an outbound JSON request and return any parsed JSON payload.
Provider APIs may return either objects or arrays. Callers that require a
specific shape must validate the returned value before using it.
"""
from .type_utils import DEFAULT_BROWSER_USER_AGENT
+15 -3
View File
@@ -1390,9 +1390,10 @@ def get_model_info_by_hash(
api_url,
api_key=api_key,
timeout=15,
raise_on_error=True,
)
if data:
if isinstance(data, dict) and data:
# Extract useful info from the response
model_info = data.get("model", {})
version_info = data
@@ -1435,10 +1436,21 @@ def get_model_info_by_hash(
log.info(f"Found model by hash {file_hash}: {result.get('model_name')}")
return result
else:
log.info(f"Model not found or lookup failed on CivitAI for hash {file_hash}")
log.warning(
f"CivitAI hash lookup returned an unexpected JSON payload for {file_hash}"
)
return None
except requests.HTTPError as e:
status_code = getattr(e.response, "status_code", None)
if status_code == 404:
log.info(f"Model not found on CivitAI for hash {file_hash}")
_hash_cache[cache_key] = None
return None
log.warning(
f"CivitAI hash lookup returned HTTP {status_code or 'error'} for {file_hash}"
)
return None
except Exception as e:
log.error(f"Error looking up model by hash {file_hash}: {e}")
+33
View File
@@ -16,6 +16,39 @@ from core.downloader import (
class DownloaderMetadataSidecarTests(unittest.TestCase):
def test_civarchive_metadata_keeps_provider_page_separate_from_hf_mirror(self):
with tempfile.TemporaryDirectory() as tmpdir:
model_path = os.path.join(tmpdir, "model.safetensors")
with open(model_path, "wb") as handle:
handle.write(b"abc")
payload = build_lora_manager_metadata(
model_path,
{
"source": "civarchive",
"details_source": "civarchive",
"model_id": 123,
"version_id": 456,
"version_url": (
"https://huggingface.co/author/repo/blob/main/model.safetensors"
),
"url": "https://civarchive.com/models/123?modelVersionId=456",
"download_url": (
"https://huggingface.co/author/repo/resolve/main/model.safetensors"
),
"sha256": "a" * 64,
},
category="checkpoints",
)
expected_page = "https://civarchive.com/models/123?modelVersionId=456"
self.assertEqual(expected_page, payload["source_url"])
self.assertEqual(expected_page, payload["version_url"])
self.assertEqual(
"https://huggingface.co/author/repo/resolve/main/model.safetensors",
payload["download_url"],
)
def test_huggingface_metadata_does_not_create_civitai_identity(self):
with tempfile.TemporaryDirectory() as tmpdir:
model_path = os.path.join(tmpdir, "model.safetensors")
+94 -1
View File
@@ -2,7 +2,8 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizePathIdentity } from '../web/resolver/utils/html_utils.js';
import { html, normalizePathIdentity } from '../web/resolver/utils/html_utils.js';
import { getModelCardUrl } from '../web/resolver/utils/url_utils.js';
const projectRoot = path.resolve(import.meta.dirname, '..');
const queueMethodsSource = fs.readFileSync(
@@ -38,6 +39,15 @@ const renderFormatMethodsSource = fs.readFileSync(
'utf8'
);
test('html template escapes every item in interpolated arrays', () => {
const rendered = html`<div>${['<script>', '"quoted"', '&value']}</div>`;
assert.equal(
rendered,
'<div>&lt;script&gt;&quot;quoted&quot;&amp;value</div>'
);
});
function extractMethod(source, methodName, paramsPattern = '[^)]*') {
const signatureRegex = new RegExp(`\\n\\s+(async\\s+)?${methodName}\\s*\\(${paramsPattern}\\)\\s*\\{`);
const match = signatureRegex.exec(source);
@@ -637,6 +647,89 @@ test('manual URL download metadata never inherits provider identity or hash from
assert.equal('civitai' in metadata, false);
});
test('CivArchive metadata keeps its page URL when the download mirror is HuggingFace', () => {
const getDownloadMetadata = eval(`(${extractMethod(downloadTargetMethodsSource, 'getDownloadMetadata')})`);
const civarchivePage = 'https://civarchive.com/models/123?modelVersionId=456';
const huggingFaceMirror = 'https://huggingface.co/author/repo/resolve/main/model.safetensors';
const source = {
source: 'civarchive',
details_source: 'civarchive',
model_id: 123,
version_id: 456,
filename: 'model.safetensors',
url: civarchivePage,
version_url: 'https://huggingface.co/stale/repo/blob/main/model.safetensors',
download_url: huggingFaceMirror
};
const dialog = {
getCachedSearchSuggestionData() {
return {
source: 'huggingface',
version_url: 'https://huggingface.co/stale/repo/blob/main/model.safetensors'
};
},
getCompatibleCivitaiSearchResult() {
return {};
},
getFilenameFromPath(value = '') {
return String(value).split(/[\\/]/).at(-1) || '';
}
};
const metadata = getDownloadMetadata.call(dialog, {
original_path: source.filename,
category: 'checkpoints'
}, source, {
filename: source.filename,
category: 'checkpoints',
url: huggingFaceMirror,
openUrl: '',
pathMetadata: { filename: source.filename, category: 'checkpoints' }
});
assert.equal(metadata.source, 'civarchive');
assert.equal(metadata.url, civarchivePage);
assert.equal(metadata.version_url, civarchivePage);
assert.equal(metadata.page_url, civarchivePage);
assert.equal(metadata.download_url, huggingFaceMirror);
});
test('source link prefers the selected provider page over another provider mirror', () => {
const getContextMenuSourceLink = eval(`(${extractMethod(modelInfoMethodsSource, 'getContextMenuSourceLink')})`);
const civarchivePage = 'https://civarchive.com/models/123?modelVersionId=456';
const dialog = {
getContextMenuSourceKey() {
return 'civarchive';
},
getContextMenuSourceUrlCandidates() {
return [
'https://huggingface.co/author/repo/blob/main/model.safetensors',
civarchivePage
];
},
getContextMenuSourceKeyFromUrl(value = '') {
if (value.includes('civarchive.com')) return 'civarchive';
if (value.includes('huggingface.co')) return 'huggingface';
return '';
},
normalizeContextMenuSourceUrl(value = '') {
return value;
},
getContextMenuSourceConfig(key = '') {
return { key, label: key === 'civarchive' ? 'CivArchive' : 'HuggingFace' };
},
buildContextMenuSourceUrlFromIds() {
return '';
}
};
const result = getContextMenuSourceLink.call(dialog, {});
assert.equal(result.key, 'civarchive');
assert.equal(result.label, 'CivArchive');
assert.equal(result.url, civarchivePage);
});
test('manual URL table row preserves the explicit provenance boundary', () => {
const getCustomUrlResultTableRow = eval(`(${extractMethod(searchPanelMethodsSource, 'getCustomUrlResultTableRow')})`);
const result = {
+49
View File
@@ -16,6 +16,8 @@ import tempfile
import unittest
from unittest.mock import MagicMock, patch
import requests
# ---------------------------------------------------------------------------
# civarchive internals
# ---------------------------------------------------------------------------
@@ -47,6 +49,7 @@ from core.sources.civitai import (
_search_civitai_public_api_candidates,
_search_civitai_red_candidates,
_search_civitai_trpc_candidates,
get_model_info_by_hash,
get_model_info_for_file,
search_civitai_for_file,
)
@@ -95,6 +98,52 @@ class CivitaiResultBuilderTests(unittest.TestCase):
self.assertEqual(10 * 1024, result["size"])
class CivitaiHashLookupCacheTests(unittest.TestCase):
def setUp(self):
clear_search_cache()
def tearDown(self):
clear_search_cache()
@staticmethod
def _http_error(status_code):
response = requests.Response()
response.status_code = status_code
return requests.HTTPError(response=response)
def test_only_not_found_response_is_cached(self):
with patch(
"core.sources.civitai.execute_provider_json_request",
side_effect=self._http_error(404),
) as request_json:
self.assertIsNone(get_model_info_by_hash("a" * 64))
self.assertIsNone(get_model_info_by_hash("a" * 64))
request_json.assert_called_once()
self.assertTrue(request_json.call_args.kwargs["raise_on_error"])
def test_transient_http_error_is_not_cached(self):
with patch(
"core.sources.civitai.execute_provider_json_request",
side_effect=self._http_error(503),
) as request_json:
self.assertIsNone(get_model_info_by_hash("b" * 64))
self.assertIsNone(get_model_info_by_hash("b" * 64))
self.assertEqual(request_json.call_count, 2)
def test_unexpected_json_shape_is_not_cached(self):
with patch(
"core.sources.civitai.execute_provider_json_request",
return_value=[],
) as request_json:
self.assertIsNone(get_model_info_by_hash("c" * 64))
self.assertIsNone(get_model_info_by_hash("c" * 64))
self.assertEqual(request_json.call_count, 2)
# ===========================================================================
# civitai - local safetensors fallback
# ===========================================================================
+26
View File
@@ -1143,6 +1143,19 @@ class UnifiedHelpersTests(unittest.TestCase):
res = request_source_json("https://example.com/api")
self.assertEqual(res, {"success": True})
@patch("requests.get")
def test_request_source_json_accepts_array_payload(self, mock_get):
from core.network_utils import request_source_json
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [{"id": "author/model"}]
mock_get.return_value = mock_response
res = request_source_json("https://example.com/api")
self.assertEqual(res, [{"id": "author/model"}])
@patch("requests.get")
@patch("time.sleep")
def test_request_source_json_429_retry(self, mock_sleep, mock_get):
@@ -1216,4 +1229,17 @@ class UnifiedHelpersTests(unittest.TestCase):
call_kwargs = mock_request_json.call_args[1]
self.assertIn("Cookie", call_kwargs["headers"])
@patch("core.network_utils.request_source_json")
def test_execute_provider_json_request_accepts_array_payload(
self, mock_request_json
):
from core.network_utils import execute_provider_json_request
mock_request_json.return_value = [{"id": "author/model"}]
result = execute_provider_json_request(
"HuggingFace API",
"https://huggingface.co/api/models",
)
self.assertEqual(result, [{"id": "author/model"}])
+38 -2
View File
@@ -1412,6 +1412,39 @@ export const downloadTargetMethods = {
|| merged.details_source
|| merged.source
|| '';
const sourcePageCandidates = [
options.openUrl,
sourceData.version_url,
sourceData.versionUrl,
sourceData.model_url,
sourceData.modelUrl,
sourceData.page_url,
sourceData.pageUrl,
sourceData.url,
merged.model_url,
merged.page_url,
merged.version_url,
merged.url
].filter(Boolean);
let sourcePageUrl = sourcePageCandidates[0] || '';
const isCivArchiveSource = String(sourceName).toLowerCase().replace(/[-\s]+/g, '_') === 'civarchive';
if (isCivArchiveSource) {
const civarchivePageUrl = sourcePageCandidates.find(value => {
try {
const host = new URL(String(value), 'https://civarchive.com').hostname.toLowerCase();
return host === 'civarchive.com' || host.endsWith('.civarchive.com');
} catch {
return false;
}
});
const sourceModelId = idSource.model_id || idSource.modelId || '';
const sourceVersionId = idSource.version_id || idSource.versionId || '';
sourcePageUrl = civarchivePageUrl || (
sourceModelId
? `https://civarchive.com/models/${sourceModelId}${sourceVersionId ? `?modelVersionId=${sourceVersionId}` : ''}`
: ''
);
}
const metadata = {
source: sourceName,
details_source: sourceData.details_source || sourceData.source || sourceName,
@@ -1431,8 +1464,11 @@ export const downloadTargetMethods = {
creator,
author: merged.author || pathMetadata.author || '',
repo_id: repoId,
url: options.openUrl || merged.model_url || merged.version_url || merged.url || '',
version_url: merged.version_url || '',
url: sourcePageUrl,
version_url: isCivArchiveSource ? sourcePageUrl : (sourceData.version_url || sourceData.versionUrl || sourcePageUrl),
page_url: isCivArchiveSource ? sourcePageUrl : (sourceData.page_url || sourceData.pageUrl || sourcePageUrl),
model_url: isCivArchiveSource ? sourcePageUrl : (sourceData.model_url || sourceData.modelUrl || sourcePageUrl),
platform_url: sourceData.platform_url || sourceData.platformUrl || merged.platform_url || merged.platformUrl || '',
download_url: options.url || merged.download_url || merged.downloadUrl || merged.url || '',
size: merged.size || merged.size_bytes || '',
sha256: merged.sha256 || merged.hash || merged.hashes?.SHA256 || merged.hashes?.sha256 || '',
+3 -1
View File
@@ -27,7 +27,9 @@ export function html(strings, ...values) {
const value = values[i - 1];
let valStr = '';
if (value !== undefined && value !== null) {
valStr = Array.isArray(value) ? value.join('') : escapeHtml(value);
valStr = Array.isArray(value)
? value.map(item => escapeHtml(item)).join('')
: escapeHtml(value);
}
return result + valStr + string;
});
+23 -7
View File
@@ -291,11 +291,10 @@ export const modelInfoMethods = {
getContextMenuSourceLink(model = {}) {
const fallbackKey = this.getContextMenuSourceKey(model);
const candidates = this.getContextMenuSourceUrlCandidates(model);
for (const candidate of candidates) {
const resolveCandidate = (candidate) => {
const candidateKey = this.getContextMenuSourceKeyFromUrl(candidate) || fallbackKey;
const normalizedUrl = this.normalizeContextMenuSourceUrl(candidate, candidateKey);
if (!normalizedUrl) continue;
if (!normalizedUrl) return null;
const cardUrl = getModelCardUrl(normalizedUrl) || normalizedUrl;
const sourceKey = this.getContextMenuSourceKeyFromUrl(cardUrl)
@@ -306,6 +305,17 @@ export const modelInfoMethods = {
...config,
url: cardUrl
};
};
const preferredCandidates = fallbackKey
? candidates.filter(candidate => {
const candidateKey = this.getContextMenuSourceKeyFromUrl(candidate);
return !candidateKey || candidateKey === fallbackKey;
})
: candidates;
for (const candidate of preferredCandidates) {
const resolved = resolveCandidate(candidate);
if (resolved) return resolved;
}
const builtUrl = this.buildContextMenuSourceUrlFromIds(model, fallbackKey);
@@ -317,6 +327,12 @@ export const modelInfoMethods = {
};
}
for (const candidate of candidates) {
if (preferredCandidates.includes(candidate)) continue;
const resolved = resolveCandidate(candidate);
if (resolved) return resolved;
}
return null;
},
@@ -2096,11 +2112,11 @@ export const modelInfoMethods = {
const civitaiLinkEl = dialog.querySelector('.mr-info-civitai-link');
if (civitaiLinkEl) {
const hasLocalFile = Boolean(this.getInfoDialogModelFilePath(data));
if (data.url || data.version_url) {
const url = data.version_url || data.url;
const sourceLabel = this.getInfoMetadataSourceLabel(data);
const sourceLink = this.getContextMenuSourceLink(data);
if (sourceLink?.url) {
const sourceLabel = sourceLink.label || this.getInfoMetadataSourceLabel(data);
civitaiLinkEl.innerHTML = `
<a href="${this.escapeHtml(url)}" target="_blank" class="mr-info-link">
<a href="${this.escapeHtml(sourceLink.url)}" target="_blank" class="mr-info-link">
View on ${this.escapeHtml(sourceLabel)}
${this.getInfoMetadataSourceIcon(data)}
</a>