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:
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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><script>"quoted"&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 = {
|
||||
|
||||
@@ -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
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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"}])
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user