From 18f3208468d3a2c0a3eb40c2a3f5f94149c475de Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 9 Oct 2026 08:12:56 +0800 Subject: [PATCH] fix(civitai): surface missing-model diagnostics and anchor image-page URL parsing - Return the ResourceNotFoundError detail from get_civitai_versions and get_model_versions_status instead of a bare "Model not found", and let the download modal display the server-provided message - Anchor extract_civitai_image_id / extractCivitaiImageId to ^/images/ so API paths and partial numeric IDs are rejected, same as the model-page parsing fix in #1140 --- py/routes/handlers/misc_handlers.py | 5 +-- py/routes/handlers/model_handlers.py | 4 +-- py/utils/civitai_utils.py | 2 +- static/js/api/baseModelApi.js | 2 +- static/js/utils/civitaiUtils.js | 2 +- tests/frontend/utils/civitaiUtils.test.js | 6 ++++ tests/routes/test_misc_routes.py | 33 ++++++++++++++++++++ tests/routes/test_model_update_handler.py | 38 +++++++++++++++++++++++ tests/utils/test_civitai_utils.py | 15 ++++++++- 9 files changed, 99 insertions(+), 8 deletions(-) diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index 459a6221..4cdb55b3 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -2881,9 +2881,10 @@ class ModelLibraryHandler: try: response = await metadata_provider.get_model_versions(model_id) - except ResourceNotFoundError: + except ResourceNotFoundError as exc: return web.json_response( - {"success": False, "error": "Model not found"}, status=404 + {"success": False, "error": str(exc) or "Model not found"}, + status=404, ) if not response or not response.get("modelVersions"): return web.json_response( diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 4c016863..ff9730f7 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -2359,8 +2359,8 @@ class ModelCivitaiHandler: metadata_provider = await self._metadata_provider_factory() try: response = await metadata_provider.get_model_versions(model_id) - except ResourceNotFoundError: - return web.Response(status=404, text="Model not found") + except ResourceNotFoundError as exc: + return web.json_response({"error": str(exc)}, status=404) if not response or not response.get("modelVersions"): return web.Response(status=404, text="Model not found") diff --git a/py/utils/civitai_utils.py b/py/utils/civitai_utils.py index 3a232ded..f40f7338 100644 --- a/py/utils/civitai_utils.py +++ b/py/utils/civitai_utils.py @@ -168,7 +168,7 @@ def extract_civitai_image_id(url: str | None) -> str | None: if parsed is None: return None - path_match = re.search(r"/images/(\d+)", parsed.path) + path_match = re.match(r"^/images/(\d+)(?:/|$)", parsed.path) if not path_match: return None diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 2b7ea64f..a5757831 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1310,7 +1310,7 @@ export class BaseModelApiClient { if (errorData && errorData.error && errorData.error.includes('Model type mismatch')) { throw new Error(`This model is not a ${this.apiConfig.config.displayName}. Please switch to the appropriate page to download this model type.`); } - throw new Error('Failed to fetch model versions'); + throw new Error((errorData && errorData.error) || 'Failed to fetch model versions'); } return await response.json(); } catch (error) { diff --git a/static/js/utils/civitaiUtils.js b/static/js/utils/civitaiUtils.js index 7f5bb2a4..cc927d8b 100644 --- a/static/js/utils/civitaiUtils.js +++ b/static/js/utils/civitaiUtils.js @@ -304,7 +304,7 @@ export function extractCivitaiImageId(url) { return null; } - const pathMatch = parsedUrl.pathname.match(/\/images\/(\d+)/); + const pathMatch = parsedUrl.pathname.match(/^\/images\/(\d+)(?:\/|$)/); return pathMatch ? pathMatch[1] : null; } catch (e) { return null; diff --git a/tests/frontend/utils/civitaiUtils.test.js b/tests/frontend/utils/civitaiUtils.test.js index 36de32cd..d905a366 100644 --- a/tests/frontend/utils/civitaiUtils.test.js +++ b/tests/frontend/utils/civitaiUtils.test.js @@ -379,6 +379,12 @@ describe('civitaiUtils', () => { it('rejects image-like URLs from unsupported hosts', () => { expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null); }); + + it.each(['civitai.com', 'civitai.red'])('rejects non-image pages on %s', (host) => { + for (const path of ['/api/v1/images/126920345', '/images/126920345invalid']) { + expect(extractCivitaiImageId(`https://${host}${path}`)).toBe(null); + } + }); }); describe('classifyModelRelinkUrl', () => { diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 2c0a0974..92656c56 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -37,6 +37,7 @@ from py.utils.session_logging import ( ) from py.routes.misc_route_registrar import MISC_ROUTE_DEFINITIONS, MiscRouteRegistrar from py.routes.misc_routes import MiscRoutes +from py.services.errors import ResourceNotFoundError def _json_payload(response) -> dict[str, Any]: @@ -2748,6 +2749,38 @@ async def test_get_model_versions_status_supported_type_stays_interactive(): ] +@pytest.mark.asyncio +async def test_get_model_versions_status_surfaces_not_found_details(): + """A missing CivitAI model keeps the provider's diagnostic message.""" + + class NotFoundProvider: + async def get_model_versions(self, _model_id): + raise ResourceNotFoundError("Civitai model 3390681 was not found") + + async def metadata_factory(): + return NotFoundProvider() + + handler = ModelLibraryHandler( + ServiceRegistryAdapter( + get_lora_scanner=fake_scanner_factory, + get_checkpoint_scanner=fake_scanner_factory, + get_embedding_scanner=fake_scanner_factory, + get_other_scanner=fake_scanner_factory, + get_downloaded_version_history_service=fake_download_history_service_factory, + ), + metadata_provider_factory=metadata_factory, + ) + + response = await handler.get_model_versions_status( + FakeRequest(query={"modelId": "3390681"}) # pyright: ignore[reportArgumentType] + ) + payload = _json_payload(response) + + assert response.status == 404 + assert payload["success"] is False + assert "3390681" in payload["error"] + + class DummySidecarMigrationUseCase: def __init__(self, result): self.result = result diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index 77f17479..66f06df0 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -15,6 +15,7 @@ from py.routes.handlers.model_handlers import ( ModelUpdateHandler, ) from py.routes.model_route_registrar import COMMON_ROUTE_DEFINITIONS +from py.services.errors import ResourceNotFoundError from py.services.service_registry import ServiceRegistry from py.utils.metadata_manager import MetadataManager from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord @@ -231,6 +232,43 @@ async def test_get_civitai_versions_degrades_when_download_history_unavailable(m assert payload[0]["hasBeenDownloaded"] is False +@pytest.mark.asyncio +async def test_get_civitai_versions_surfaces_not_found_details(): + cache = SimpleNamespace(version_index={}) + service = DummyService(cache) + + class NotFoundProvider: + async def get_model_versions(self, model_id): + raise ResourceNotFoundError(f"Civitai model {model_id} was not found") + + async def metadata_provider_factory(): + return NotFoundProvider() + + handler = ModelCivitaiHandler( + service=service, + settings_service=SimpleNamespace(get=lambda *_: False), # pyright: ignore[reportArgumentType] + ws_manager=SimpleNamespace(), # pyright: ignore[reportArgumentType] + logger=logging.getLogger(__name__), + metadata_provider_factory=metadata_provider_factory, + validate_model_type=lambda *_: True, + expected_model_types=lambda: "LoRA", + find_model_file=lambda *_: None, + metadata_sync=SimpleNamespace(), # pyright: ignore[reportArgumentType] + metadata_refresh_use_case=SimpleNamespace(), # pyright: ignore[reportArgumentType] + metadata_progress_callback=lambda *_args, **_kwargs: None, # pyright: ignore[reportArgumentType] + ) + + response = await handler.get_civitai_versions( + SimpleNamespace(match_info={"model_id": "42"}) # pyright: ignore[reportArgumentType] + ) + text = response.text + assert text is not None + payload = json.loads(text) + + assert response.status == 404 + assert "42" in payload["error"] + + @pytest.mark.asyncio async def test_refresh_model_updates_filters_records_without_updates(): cache = SimpleNamespace(version_index={}) diff --git a/tests/utils/test_civitai_utils.py b/tests/utils/test_civitai_utils.py index 6b3bbc05..f1b42ddb 100644 --- a/tests/utils/test_civitai_utils.py +++ b/tests/utils/test_civitai_utils.py @@ -10,14 +10,27 @@ from py.utils.civitai_utils import ( resolve_license_payload, ) + @pytest.mark.parametrize("host", ["civitai.com", "civitai.red"]) -@pytest.mark.parametrize("path", ["/api/download/models/3390681", "/api/v1/models/3390681", "/models/3390681invalid"]) +@pytest.mark.parametrize( + "path", + ["/api/download/models/3390681", "/api/v1/models/3390681", "/models/3390681invalid"], +) def test_extract_civitai_model_url_parts_rejects_non_model_pages(host, path): assert extract_civitai_model_url_parts( f"https://{host}{path}?modelVersionId=3390681" ) == (None, None) +@pytest.mark.parametrize("host", ["civitai.com", "civitai.red"]) +@pytest.mark.parametrize( + "path", + ["/api/v1/images/126920345", "/images/126920345invalid"], +) +def test_extract_civitai_image_id_rejects_non_image_pages(host, path): + assert extract_civitai_image_id(f"https://{host}{path}") is None + + def test_resolve_license_payload_defaults(): payload, flags = resolve_license_info({})