fix(models): import upload and Tidy moves only from this computer

PUT /anomalous/import/upload and POST /anomalous/placement/move wrote
into the model folders for any caller, while place and identify already
answered only the computer ComfyUI runs on. With ComfyUI started with
--listen, another device could upload model files or move models
between folders. Both now check mcp_server._is_local first and answer
403 not_local, which the import and Tidy screens already show.

Tests drive both routes through a proxy header and check nothing was
written or moved; they fail without the check. Full suite: structure
check, every tests/*.mjs and 234 Python tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018P2T2R7TFbJsc3C9maCD8B
This commit is contained in:
Claude
2026-10-08 20:35:57 +00:00
parent 1b8839d2c6
commit ca2cdecf7e
6 changed files with 40 additions and 3 deletions
+1
View File
@@ -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.
+3
View File
@@ -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)
+5 -1
View File
@@ -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):
+2 -2
View File
@@ -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 `<dest>.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 `<dest>.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
+12
View File
@@ -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"]
+17
View File
@@ -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()