Files
DemonGatanjieu-Anomalous_Mo…/api/model_import.py
T
Claude ca2cdecf7e 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
2026-10-08 20:35:57 +00:00

397 lines
15 KiB
Python

"""Putting a model file the user drops on the models page into the right models folder.
A browser gives a dropped file's name, size, time and bytes, never its path. So:
GET /anomalous/import/folders -> {types: {type: roots}, settings}: the folders a model can go to
(model_download.roots_of) and the download settings, whose folder rule ({base}) imports share.
POST /anomalous/import/inspect?name&size&mtime body: the file's first bytes (its header)
-> what it is (model_kind.classify), files of the same name already there, and, when this
computer's Downloads or Desktop holds that very file (same name, size and time; asked only
by the computer ComfyUI runs on), a `token` for it.
POST /anomalous/import/identify {token} -> its SHA-256, what Civitai knows of it (base model,
model and version name) and a scanned model with the same SHA-256, if any.
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).
"""
import asyncio
import os
import shutil
import threading
import time
import uuid
from aiohttp import web
import folder_paths
try:
from ..model_identity import file_sha256
from ..model_kind import classify, inspect_file, parse_gguf, parse_safetensors
except ImportError: # loaded outside the package (tests)
from model_identity import file_sha256
from model_kind import classify, inspect_file, parse_gguf, parse_safetensors
from .activity_log import add_entry
from .download_sources import SourceUnreachable, civitai_by_hash
from .mcp_server import _is_local
from .model_download import (
CHUNK, MODEL_EXTENSIONS, SPACE_MARGIN, clean_folder, folder_type_for, forget_file_lists, read_settings, roots_of,
)
from .path_utils import resolve_within
KINDS = ("checkpoints", "loras", "vae", "text_encoders", "diffusion_models", "controlnet", "embeddings",
"clip_vision", "upscale_models", "ipadapter")
SEARCH_DEPTH = 2
SEARCH_LIMIT = 20000
TIME_SLACK_MS = 2000
KEEP_TOKENS = 200
_FOLDER_IDS = { # Windows known folders: Downloads, Desktop
"downloads": "{374DE290-123F-4565-9164-39C4925E467B}",
"desktop": "{B4BFCC3A-DB2C-424C-B029-7FE99A87C641}",
}
_lock = threading.Lock()
_tokens = {} # token -> {path, name, size}
class Refused(Exception):
"""An import that cannot be done; `code` says why (the page has a sentence for each)."""
def __init__(self, code):
super().__init__(code)
self.code = code
def _error(code, status=400):
return web.json_response({"error": code}, status=status)
# --- where the dropped file is on this computer --------------------------------------------
def _known_folder(name):
if os.name == "nt":
try:
import ctypes
from ctypes import wintypes
folder_id = uuid.UUID(_FOLDER_IDS[name]).bytes_le
path = ctypes.c_wchar_p()
shell = ctypes.windll.shell32
shell.SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, wintypes.DWORD, wintypes.HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
if shell.SHGetKnownFolderPath(folder_id, 0, None, ctypes.byref(path)) == 0:
found = path.value
ctypes.windll.ole32.CoTaskMemFree(path)
if found and os.path.isdir(found):
return found
except Exception:
pass
fallback = os.path.expanduser("~/" + name.capitalize())
return fallback if os.path.isdir(fallback) else ""
def search_folders():
"""[(label, path)] where dropped files usually come from: Downloads, then Desktop."""
found = []
for label in ("downloads", "desktop"):
path = _known_folder(label)
if path and all(os.path.realpath(path) != os.path.realpath(other) for _, other in found):
found.append((label, path))
return found
def find_original(name, size, mtime_ms, folders=None):
"""(label, path) of the file with this name, size and time in `folders` (and their
subfolders two deep), else None."""
wanted = name.lower()
seen = 0
for label, base in folders if folders is not None else search_folders():
for current, dirs, files in os.walk(base):
depth = 0 if current == base else os.path.relpath(current, base).count(os.sep) + 1
dirs[:] = sorted(item for item in dirs if not item.startswith(".")) if depth < SEARCH_DEPTH else []
for file in files:
seen += 1
if seen > SEARCH_LIMIT:
return None
if file.lower() != wanted:
continue
path = os.path.join(current, file)
try:
stat = os.stat(path)
except OSError:
continue
if stat.st_size == size and (not mtime_ms or abs(stat.st_mtime * 1000 - mtime_ms) <= TIME_SLACK_MS):
return label, path
return None
def _remember(path, name, size):
token = uuid.uuid4().hex
with _lock:
_tokens[token] = {"path": path, "name": name, "size": size}
while len(_tokens) > KEEP_TOKENS:
_tokens.pop(next(iter(_tokens)))
return token
def _original(token):
with _lock:
item = _tokens.get(str(token or ""))
if not item or not os.path.isfile(item["path"]) or os.path.getsize(item["path"]) != item["size"]:
raise Refused("gone")
return item
# --- what it is ----------------------------------------------------------------------------
def import_types():
known = getattr(folder_paths, "folder_names_and_paths", {})
return [kind for kind in KINDS if kind in known]
def classify_head(name, head):
"""classify() from a file's first bytes, by its extension."""
lower = name.lower()
if lower.endswith((".safetensors", ".sft")):
return classify("safetensors", parse_safetensors(head))
if lower.endswith(".gguf"):
return classify("gguf", parse_gguf(head))
return classify(None, None)
def same_name(kind, name):
"""Files of this name already in the type's folders: [{root, rel, size}]."""
if not kind:
return []
wanted, found = name.lower(), []
for index, base in enumerate(folder_paths.get_folder_paths(kind) or []):
if not os.path.isdir(base):
continue
for current, _dirs, files in os.walk(base):
for file in files:
if file.lower() == wanted:
path = os.path.join(current, file)
found.append({"root": index, "rel": os.path.relpath(path, base).replace(os.sep, "/"),
"size": os.path.getsize(path)})
return found[:10]
def inspect(name, size, mtime_ms, head, local):
found = find_original(name, size, mtime_ms) if local else None
if found:
result = inspect_file(found[1])
result.pop("format", None)
result.update(token=_remember(found[1], name, size), where=found[0])
else:
result = classify_head(name, head)
if result["kind"] not in import_types():
result.update(kind="", sure=False)
result["same_name"] = same_name(result["kind"], name)
return result
def _same_hash(sha256):
"""Where a scanned model with this SHA-256 is ("sub/name.safetensors"), else None."""
from .model_resolution import collect_model_hash_index
names = [key for key, value in collect_model_hash_index().items() if str(value.get("hash") or "").lower() == sha256]
return next((key for key in names if "/" in key), names[0] if names else None)
def identify(token):
"""SHA-256, Civitai's record, and an already scanned copy, of a found file."""
item = _original(token)
sha256 = file_sha256(item["path"])
result = {"sha256": sha256, "civitai": None, "reason": "", "duplicate": _same_hash(sha256)}
try:
found = civitai_by_hash(sha256)
if found:
result["civitai"] = {key: found.get(key, "") for key in ("base_model", "model_name", "version_name", "page")}
else:
result["reason"] = "not_found"
except SourceUnreachable:
result["reason"] = "network"
return result
# --- putting it there ----------------------------------------------------------------------
def free_path(path):
"""`path`, or "name (2).ext", "name (3).ext"… when it is taken."""
if not os.path.exists(path) and not os.path.exists(path + ".part"):
return path
stem, ext = os.path.splitext(path)
number = 2
while os.path.exists(f"{stem} ({number}){ext}") or os.path.exists(f"{stem} ({number}){ext}.part"):
number += 1
return f"{stem} ({number}){ext}"
def destination(data, size=0):
"""(type, root index, root path, dest) for a place/upload request; Refused if refused."""
folder_type = folder_type_for([data.get("type")])
if folder_type not in import_types():
raise Refused("bad_type")
paths = folder_paths.get_folder_paths(folder_type) or []
try:
root_index = int(data.get("root", 0))
except (TypeError, ValueError):
raise Refused("bad_root")
if not 0 <= root_index < len(paths):
raise Refused("bad_root")
try:
rel = clean_folder(data.get("rel"))
root = os.path.realpath(paths[root_index])
dest = resolve_within(root, *rel.split("/"))
except ValueError:
raise Refused("bad_path")
if not rel.lower().endswith(MODEL_EXTENSIONS):
raise Refused("bad_name")
probe = root
while probe and not os.path.isdir(probe):
probe = os.path.dirname(probe)
if size and probe and shutil.disk_usage(probe).free < size + SPACE_MARGIN:
raise Refused("no_space")
os.makedirs(os.path.dirname(dest), exist_ok=True)
return folder_type, root_index, root, free_path(dest)
def _same_drive(first, second):
try:
return os.stat(first).st_dev == os.stat(os.path.dirname(second)).st_dev
except OSError:
return False
def move_into(source, dest, keep):
"""Moves (or with `keep` copies) `source` to `dest`; a copy goes through "<dest>.part"."""
if not keep and _same_drive(source, dest):
os.replace(source, dest)
return
part = dest + ".part"
try:
shutil.copyfile(source, part)
if os.path.getsize(part) != os.path.getsize(source):
raise Refused("incomplete")
os.replace(part, dest)
except BaseException:
if os.path.exists(part):
os.remove(part) # our own unfinished copy
raise
if not keep:
os.remove(source) # moved: the whole file is at dest now
def _finish(folder_type, root_index, root, dest, how, size):
forget_file_lists()
rel = os.path.relpath(dest, root).replace(os.sep, "/")
add_entry("file", "model_import", rel, {"import": {
"type": folder_type, "path_idx": root_index, "rel": rel, "file": os.path.basename(dest), "size": size, "how": how,
}})
return {"type": folder_type, "root": root_index, "rel": rel, "file": os.path.basename(dest)}
def place(data):
item = _original(data.get("token"))
keep = data.get("keep") is True
folder_type, root_index, root, dest = destination(data, item["size"] if keep else 0)
move_into(item["path"], dest, keep)
if not keep:
with _lock:
_tokens.pop(str(data.get("token")), None)
return _finish(folder_type, root_index, root, dest, "copy" if keep else "move", item["size"])
# --- routes --------------------------------------------------------------------------------
async def api_folders(request):
types = {}
for kind in import_types():
types[kind] = await asyncio.to_thread(roots_of, kind)
return web.json_response({"types": types, "settings": read_settings()})
async def api_inspect(request):
name = os.path.basename(str(request.query.get("name") or "").replace("\\", "/"))
if not name.lower().endswith(MODEL_EXTENSIONS):
return _error("not_model")
try:
size = int(request.query.get("size") or 0)
mtime_ms = float(request.query.get("mtime") or 0)
except ValueError:
return _error("bad_request")
head = await request.read()
result = await asyncio.to_thread(inspect, name, size, mtime_ms, head, _is_local(request))
return web.json_response(result)
async def api_identify(request):
if not _is_local(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(identify, data.get("token")))
except Refused as error:
return _error(error.code, 404)
except (ValueError, AttributeError):
return _error("bad_request")
async def api_place(request):
if not _is_local(request):
return _error("not_local", 403)
try:
data = await request.json()
return web.json_response(await asyncio.to_thread(place, data))
except Refused as error:
return _error(error.code, 404 if error.code == "gone" else 400)
except (ValueError, AttributeError):
return _error("bad_request")
except OSError as error:
print(f"[Anomalous Browser] Import: could not put the file in place: {error}")
return _error("io", 500)
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)
except Refused as error:
return _error(error.code)
except ValueError:
return _error("bad_request")
part = dest + ".part"
received = 0
try:
target = await asyncio.to_thread(open, part, "wb")
try:
async for chunk in request.content.iter_chunked(CHUNK):
await asyncio.to_thread(target.write, chunk)
received += len(chunk)
finally:
await asyncio.to_thread(target.close)
if size and received != size:
raise Refused("incomplete")
dest = free_path(dest) if os.path.exists(dest) else dest
await asyncio.to_thread(os.replace, part, dest)
except BaseException as error:
if os.path.exists(part):
os.remove(part) # our own unfinished upload
if isinstance(error, Refused):
return _error(error.code)
if isinstance(error, OSError):
print(f"[Anomalous Browser] Import: upload failed: {error}")
return _error("io", 500)
raise
return web.json_response(await asyncio.to_thread(_finish, folder_type, root_index, root, dest, "upload", received))
def register_routes(app):
app.router.add_get("/anomalous/import/folders", api_folders)
app.router.add_post("/anomalous/import/inspect", api_inspect)
app.router.add_post("/anomalous/import/identify", api_identify)
app.router.add_post("/anomalous/import/place", api_place)
app.router.add_put("/anomalous/import/upload", api_upload)