diff --git a/CHANGELOG.md b/CHANGELOG.md index 58aceff..890f9d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - **The models page has tabs: All models, Tidy and Sources** (Sources was a chip among the model types). Tidy shows a count when it found something. - **Tidy** reads the "table of contents" inside every model file and shows, as cards with the model's cover and name, two steps. **1. In the wrong folder**: what the file really is and where it belongs, drawn as *Checkpoint → UNet / Anima*, with one plain reason (for example an Anima, Flux or Wan model that is only the main part, which "Load Checkpoint" cannot read — Civitai labels all of these "Checkpoint"). **Move there** takes the model with its cover, info and your notes, keeping its subfolder ("Change place" to pick another); nothing is replaced, and it says which loader node your workflows need instead. **2. Identical files**: the copies side by side, the one in the right folder marked to keep (click another cover to keep that one), and one button sends the extra copies to the Recycle Bin with their covers and info. Scanned models are compared at once; same-size files never scanned when you press **Compare**. - Models that load where they are anyway (a whole checkpoint in the UNet folder) are not listed. +- Importing, uploading and Tidy's moves only work on the computer ComfyUI runs on; opened from another device (ComfyUI started with `--listen`), they say so instead of writing into your model folders. ### 🧬 Filter models by base model (按底模筛选) - Under the type chips, one chip per base model the listed models have, with counts: **All · Illustrious 21 · Anima 7 · SDXL 9 · Unknown 2**. Spellings of one family count as one (SDXL 1.0 and SDXL). Models not scanned yet are listed by what their file's header tells. Handy for LoRAs: pick the base model of your checkpoint and only the LoRAs that fit are left. It works together with the search. diff --git a/api/model_import.py b/api/model_import.py index aa3fff7..c3c3106 100644 --- a/api/model_import.py +++ b/api/model_import.py @@ -12,6 +12,7 @@ POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it POST /anomalous/import/place {token, type, root, rel, keep} -> moves that file there (a copy when `keep`); same drive = a rename, another drive = copy, then the original goes. PUT /anomalous/import/upload?type&root&rel&size body: the whole file -> written there. +identify, place and upload answer only the computer ComfyUI runs on (mcp_server._is_local). A file already at the destination is never replaced: the new one gets " (2)". Each import is one activity log entry (model_import). @@ -352,6 +353,8 @@ async def api_place(request): async def api_upload(request): + if not _is_local(request): + return _error("not_local", 403) try: size = int(request.query.get("size") or 0) folder_type, root_index, root, dest = await asyncio.to_thread(destination, dict(request.query), size) diff --git a/api/model_placement.py b/api/model_placement.py index 36bdaac..552ba87 100644 --- a/api/model_placement.py +++ b/api/model_placement.py @@ -9,7 +9,8 @@ GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked} the same size (small ones always, large ones with deep). unchecked: the same-size large files a deep check would still read. POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model - and its sidecars (covers, info, the user's notes); never over a file (" (2)"). + and its sidecars (covers, info, the user's notes); never over a file (" (2)"). Only for + the computer ComfyUI runs on (mcp_server._is_local). Removing a copy goes through /anomalous/delete_model (Recycle Bin). Headers and computed SHA-256s are kept in memory per file (path, size, time). @@ -30,6 +31,7 @@ except ImportError: # loaded outside the package (tests) from model_identity import file_sha256 from model_kind import inspect_file from .activity_log import add_entry +from .mcp_server import _is_local from .metadata import get_metadata from .model_constants import MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, SIDECAR_SUFFIXES from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists @@ -248,6 +250,8 @@ async def api_check(request): async def api_move(request): + if not _is_local(request): + return web.json_response({"error": "not_local"}, status=403) try: data = await request.json() if not isinstance(data, dict): diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 4e06241..eefb3b5 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -265,7 +265,7 @@ model, model and version name) and the scanned-model hash index for an identical `POST /anomalous/import/place` (local only) moves the found file into `{type, root, rel}`: a rename on the same drive, else a copy through `.part` checked by size, then the -original is removed (or kept with `keep`). `PUT /anomalous/import/upload` streams the request +original is removed (or kept with `keep`). `PUT /anomalous/import/upload` (local only) streams the request body (not limited by ComfyUI's upload size) into `.part` and renames it when the size matches. Both check the destination like downloads (`resolve_within`, model extension, free space) and never replace a file: a taken name becomes `name (2).ext`. A failed copy or @@ -283,7 +283,7 @@ when `sure`, is misplaced; it goes to the same relative path in a folder of that same drive when one exists. `works` marks a whole checkpoint in the diffusion models folder, which still loads there. Identical files are grouped by SHA-256: the scan's (metadata) hash, or one computed earlier this run; files of one size whose hash is unknown are only counted -(`unchecked`) unless `deep=1` reads them. `POST /anomalous/placement/move` moves the model with +(`unchecked`) unless `deep=1` reads them. `POST /anomalous/placement/move` (local only) moves the model with `model_import.destination` and `move_into` (never over a file: " (2)") and then its sidecars (`SIDECAR_SUFFIXES`; none when another model in the folder shares the stem), reports those that could not follow (`left`), clears the file-list caches and logs `model_move`. Removing a copy is diff --git a/tests/test_model_import.py b/tests/test_model_import.py index adf4a4c..cf11498 100644 --- a/tests/test_model_import.py +++ b/tests/test_model_import.py @@ -245,6 +245,18 @@ class ImportTests(unittest.TestCase): self.assertEqual((short_status, short_body["error"]), (400, "incomplete")) self.assertEqual(sorted(os.listdir(os.path.join(self.loras, "up"))), ["new.safetensors"]) + def test_upload_only_from_this_computer(self): + async def run(): + app = web.Application() + model_import.register_routes(app) + async with TestClient(TestServer(app)) as client: # through a proxy: not this computer + resp = await client.put("/anomalous/import/upload", headers={"X-Forwarded-For": "192.168.1.20"}, + params={"type": "loras", "root": "0", "rel": "far.safetensors", "size": str(len(self.data))}, + data=self.data) + return resp.status, await resp.json() + self.assertEqual(asyncio.run(run()), (403, {"error": "not_local"})) + self.assertFalse(Path(self.loras, "far.safetensors").exists()) + def test_identify_reports_civitai_and_a_copy_already_there(self): path, mtime = self.dropped("cool.safetensors") token = model_import.inspect("cool.safetensors", len(self.data), mtime, b"", local=True)["token"] diff --git a/tests/test_model_placement.py b/tests/test_model_placement.py index 6607d5a..7f0e5b7 100644 --- a/tests/test_model_placement.py +++ b/tests/test_model_placement.py @@ -1,5 +1,6 @@ """The models page's Tidy check (api/model_placement.py): models in the wrong folder, identical copies, and moving a model with its covers and info. Files go to temporary folders.""" +import asyncio import hashlib import json import os @@ -10,6 +11,9 @@ import unittest from pathlib import Path from unittest import mock +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + PLUGIN = Path(__file__).resolve().parents[1] sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] @@ -134,6 +138,19 @@ class PlacementTests(unittest.TestCase): self.assertEqual(refused.exception.code, code) self.assertTrue(Path(self.roots["checkpoints"][0], "m.safetensors").exists()) + def test_move_only_from_this_computer(self): + self.put("checkpoints", "m.safetensors", ANIMA) + move = {"type": "checkpoints", "root": 0, "rel": "m.safetensors", "to_type": "loras", "to_root": 0, "to_rel": "m.safetensors"} + + async def run(): + app = web.Application() + model_placement.register_routes(app) + async with TestClient(TestServer(app)) as client: # through a proxy: not this computer + resp = await client.post("/anomalous/placement/move", json=move, headers={"X-Forwarded-For": "192.168.1.20"}) + return resp.status, await resp.json() + self.assertEqual(asyncio.run(run()), (403, {"error": "not_local"})) + self.assertTrue(Path(self.roots["checkpoints"][0], "m.safetensors").exists()) + if __name__ == "__main__": unittest.main()