Files
malkuthro bda4df576a Release v0.4.1 (#250)
* Release v0.4.0: update footer notice

* Release v0.4.1
2026-06-19 01:38:40 +02:00

1162 lines
47 KiB
Python

"""HTTP routes for the Kforge Labs preset library.
Exposes ``/koolook/presets/*`` endpoints over ComfyUI's aiohttp server. The
preset directory is configurable via the ``KFORGELABS_PRESETS`` env var; if
unset, falls back to ``<comfyui-userdata>/koolook-presets/`` so users who
don't care about cross-machine sharing get a sensible default.
Endpoints:
GET /koolook/presets/info
GET /koolook/presets/list
GET /koolook/presets/file?name=<name>
POST /koolook/presets/file?name=<name>
DELETE /koolook/presets/file?name=<name>
Path-traversal protection: filenames are restricted to a whitelist
``^[A-Za-z0-9 _.()\\-]+\\.json$``. Anything else returns 400. We enforce at
the server boundary so the client cannot smuggle ``../`` or absolute paths
even if the JS layer is compromised.
The configured directory is auto-created on first save (parent must already
exist — for facility shares mounted via NFS/SMB the mount point itself
must be present before saves succeed).
"""
from __future__ import annotations
import json
import os
import re
import shutil
import string
import subprocess
import sys
import tempfile
from pathlib import Path
from aiohttp import web
try:
from .koolook_setups import (
DEFAULT_SETUPS_FILENAME,
DEFAULT_SETUPS_SUBDIR,
FileSetupStorage,
PublishedSetupRegistry,
SAMPLE_SETUPS_PATH,
default_storage_path,
)
from .koolook_setup_runner import (
AiohttpComfyClient,
InMemorySetupRunStore,
PublishedSetupRunner,
SetupRunError,
)
except ImportError: # pragma: no cover - standalone test/import context
from koolook_setups import (
DEFAULT_SETUPS_FILENAME,
DEFAULT_SETUPS_SUBDIR,
FileSetupStorage,
PublishedSetupRegistry,
SAMPLE_SETUPS_PATH,
default_storage_path,
)
from koolook_setup_runner import (
AiohttpComfyClient,
InMemorySetupRunStore,
PublishedSetupRunner,
SetupRunError,
)
ENV_VAR = "KFORGELABS_PRESETS"
DEFAULT_SUBDIR = "koolook-presets"
SETTINGS_FILENAME = "koolook-settings.json"
SETTINGS_KEY_LIBRARY_PATH = "libraryPath"
# Single-segment filename, ending in `.json`. No path separators, no `..`,
# no leading dot. Allows letters, digits, spaces, and a small set of safe
# punctuation (underscore, period, parentheses, hyphen).
_FILENAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+\.json$")
# Same charset as filenames but without the `.json` extension — for the
# optional `dir` query param that scopes preset operations into a per-preset
# auto-save subfolder (`<preset>_autosave/`). Single segment only; no path
# separators (otherwise the symlink-escape check would have to scan multiple
# levels). The library structure is deliberately one level deep.
_DIRNAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+$")
# Filenames the list endpoint should hide from the user-facing Load list.
# Currently used to suppress legacy flat-file autosaves left over from the
# previous autosave layout, in case a user has already accumulated some
# before upgrading to the per-preset-subfolder layout. Cheap defensive
# filter; can be removed in a future major if we choose to migrate.
_HIDDEN_LIST_PREFIXES = ("_autosave_",)
_WEB_DIR = Path(__file__).resolve().parent / "web"
_REPO_ROOT = Path(__file__).resolve().parent
_GITHUB_RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases"
_GITHUB_LATEST_RELEASE_API_URL = (
"https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest"
)
def _read_pack_version() -> str:
"""Read the installed Koolook pack version from ``pyproject.toml``.
Kept local and dependency-free so the ComfyUI runtime does not need package
metadata installed. Returns ``"unknown"`` if the file is unavailable or
malformed; the frontend treats that as "do not show update notice".
"""
try:
text = (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
except OSError:
return "unknown"
match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
return match.group(1).strip() if match else "unknown"
def _latest_autosave_mtime(autosave_dir: Path, named_mtime: float) -> float | None:
"""Returns the newest recovery-file mtime inside ``autosave_dir`` if
strictly newer than ``named_mtime``, else ``None``.
Scans ``periodic.json`` and every ``pre_load_*.json``. Powers the Load
dialog's "newer auto-save available" affordance — the dialog needs to
surface the freshest recovery file regardless of which mechanism wrote
it (the periodic timer vs. an explicit pre-load capture).
"""
newest: float | None = None
try:
st = (autosave_dir / "periodic.json").stat()
newest = st.st_mtime
except OSError:
pass
try:
for entry in autosave_dir.iterdir():
if not entry.is_file():
continue
if not entry.name.startswith("pre_load_") or not entry.name.endswith(".json"):
continue
try:
pst = entry.stat()
except OSError:
continue
if newest is None or pst.st_mtime > newest:
newest = pst.st_mtime
except OSError:
pass
if newest is not None and newest > named_mtime:
return newest
return None
def _resolve_default_dir() -> Path:
"""Resolve the default preset directory under ComfyUI's user folder.
Uses ``folder_paths.get_user_directory()`` (provided by ComfyUI core)
so we don't hardcode the userdata layout — a custom-installed Comfy
with a non-standard user dir still gets a sensible default.
"""
try:
import folder_paths
user_dir = folder_paths.get_user_directory()
return Path(user_dir) / DEFAULT_SUBDIR
except Exception: # pragma: no cover - fallback for unusual setups
return Path.cwd() / "user" / "default" / DEFAULT_SUBDIR
def _settings_file_path() -> Path:
"""Returns the path to ``koolook-settings.json`` under ComfyUI's user dir.
The settings file is per-install (so each ComfyUI on a workstation has
its own preferred library path). Stored as JSON with shape:
{ "libraryPath": "<absolute-path-or-empty>" }
"""
try:
import folder_paths
return Path(folder_paths.get_user_directory()) / SETTINGS_FILENAME
except Exception: # pragma: no cover
return Path.cwd() / "user" / "default" / SETTINGS_FILENAME
def _read_settings() -> dict:
"""Read the settings JSON. Returns ``{}`` if missing or unreadable.
Soft-failure on read: the in-UI Settings panel can still rewrite a
fresh file even if the existing one is corrupt, and the rest of the
plugin continues to function (just falls through to env var / default).
"""
path = _settings_file_path()
try:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _write_settings(data: dict) -> None:
"""Persist the settings JSON atomically.
Writes to ``<settings>.tmp`` then ``os.replace`` to the final name —
so an interrupted process or concurrent write can't leave a truncated
file that ``_read_settings`` would silently revert to ``{}`` on next
load (silently dropping the user's saved ``libraryPath``).
Raises ``web.HTTPInternalServerError`` on failure.
"""
path = _settings_file_path()
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
os.replace(str(tmp_path), str(path))
except OSError as exc:
# Best-effort cleanup of the tmp file so it doesn't accumulate.
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
raise web.HTTPInternalServerError(
reason=f"Could not write settings file: {exc}"
) from exc
def _configured_dir() -> tuple[Path, str]:
"""Return ``(dir_path, source)`` where source is one of
``"settings"``, ``"env"``, ``"default"``.
Resolution order (highest priority first):
1. ``libraryPath`` field in the settings file (set via Settings panel)
2. ``KFORGELABS_PRESETS`` env var (deployment / facility config)
3. Built-in default ``<comfyui-userdata>/koolook-presets/``
Reads each source on every call rather than caching at module-load,
so a Settings panel save or a relauncher with a new env value picks
up immediately.
"""
settings = _read_settings()
settings_path = settings.get(SETTINGS_KEY_LIBRARY_PATH, "")
if isinstance(settings_path, str) and settings_path.strip():
return Path(settings_path.strip()).expanduser(), "settings"
env = os.environ.get(ENV_VAR, "").strip()
if env:
return Path(env).expanduser(), "env"
return _resolve_default_dir(), "default"
def _published_setups_path() -> Path:
"""Storage path for published setups: a sibling of the configured snapshot
library, so they follow the same ``libraryPath`` / ``KFORGELABS_PRESETS``
location instead of a fixed user-dir folder (issue #227)."""
base, _ = _configured_dir()
return base.parent / DEFAULT_SETUPS_SUBDIR / DEFAULT_SETUPS_FILENAME
def _migrate_legacy_published_setups(legacy: Path, target: Path) -> None:
"""One-time, non-destructive copy of a pre-relocation registry from the old
fixed user-dir path (``legacy``) to ``target`` when ``target`` has none
yet. Leaves ``legacy`` in place; logs and swallows copy failures so a
permission hiccup can't take down setup serving.
The copy goes through a temp file + atomic ``os.replace`` (same pattern as
``FileSetupStorage.save_setups``) so an interrupted run can never leave a
partial ``setups.json`` at ``target`` — a partial file would otherwise
shadow the intact legacy registry on every later boot."""
if target == legacy or target.exists() or not legacy.is_file():
return
tmp_name = None
try:
target.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_name = tempfile.mkstemp(
prefix=f"{target.stem}.",
suffix=".tmp",
dir=str(target.parent),
)
os.close(tmp_fd)
shutil.copy2(legacy, tmp_name)
os.replace(tmp_name, target)
tmp_name = None
print(f"[Koolook] migrated published setups: {legacy} -> {target}")
except OSError as exc:
print(f"[Koolook] could not migrate published setups from {legacy}: {exc}")
finally:
if tmp_name and os.path.exists(tmp_name):
try:
os.unlink(tmp_name)
except OSError:
pass
def _valid_setups_registry_file(path: Path) -> bool:
"""True when ``path`` parses as a published-setups document (a bare list or
a ``{"setups": [...]}`` mapping — the shapes ``FileSetupStorage`` accepts).
Used to decide whether a relocated registry file is trustworthy: existence
alone is not proof a migration completed."""
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
if isinstance(raw, dict):
raw = raw.get("setups", [])
return isinstance(raw, list)
def _default_published_setup_registry() -> PublishedSetupRegistry:
"""Default registry factory — stores beside the snapshot library and
migrates any legacy user-dir registry on first use.
Primary selection is corruption-aware: if migration could not produce the
new file, or the file at the new path exists but does not parse while the
legacy file is intact, keep reading the legacy file in place — neither the
bundled-sample fallback nor an empty catalog may mask real, stranded
setups. The unreadable file is left untouched as evidence."""
target = _published_setups_path()
legacy = default_storage_path()
_migrate_legacy_published_setups(legacy, target)
primary = target
if target != legacy and legacy.is_file():
if not target.exists():
primary = legacy
elif not _valid_setups_registry_file(target) and _valid_setups_registry_file(legacy):
print(
f"[Koolook] relocated published-setups file at {target} is "
f"unreadable; serving intact legacy registry {legacy} instead"
)
primary = legacy
return PublishedSetupRegistry(
FileSetupStorage(primary, fallback_path=SAMPLE_SETUPS_PATH)
)
def _browse_roots() -> list[Path]:
"""Return filesystem roots the in-app browser can start from."""
if os.name == "nt":
roots = []
for letter in string.ascii_uppercase:
root = Path(f"{letter}:\\")
if root.exists():
roots.append(root)
return roots
return [Path("/")]
def _browse_start_path(raw_path: str) -> Path:
"""Resolve the requested browse path or fall back to the library dir."""
if raw_path:
return Path(raw_path).expanduser()
base, _ = _configured_dir()
if base.exists():
return base
if base.parent.exists():
return base.parent
try:
return Path.home()
except RuntimeError: # pragma: no cover - unusual host config
return Path.cwd()
def _list_child_dirs(path: Path) -> list[dict]:
out = []
for entry in path.iterdir():
try:
if not entry.is_dir():
continue
if entry.name == "_unsaved_autosave" or entry.name.endswith("_autosave"):
continue
out.append({"name": entry.name, "path": str(entry)})
except OSError:
continue
out.sort(key=lambda r: r["name"].lower())
return out
def _list_child_files(path: Path) -> list[dict]:
"""List child *.json files in ``path``.
Powers the folder picker's "yes, this is the folder I expected"
affordance (mockup section 6) — files are rendered greyed so the user
can visually confirm the listing without selecting one. Restricted to
JSON because the picker is exclusively used to choose a snapshot
library folder; surfacing non-snapshot files would be misleading
(they cannot become preset library content).
"""
out = []
for entry in path.iterdir():
try:
if not entry.is_file():
continue
if not entry.name.lower().endswith(".json"):
continue
except OSError:
continue
out.append({"name": entry.name})
out.sort(key=lambda r: r["name"].lower())
return out
def _validate_new_dir_name(name: object) -> str:
"""Validate a user-created folder name for the browse picker."""
if not isinstance(name, str):
raise web.HTTPBadRequest(reason="Folder name must be a string.")
cleaned = name.strip()
if (
not cleaned
or cleaned in {".", ".."}
or "/" in cleaned
or "\\" in cleaned
or "\x00" in cleaned
):
raise web.HTTPBadRequest(reason="Folder name must be a single folder segment.")
return cleaned
def _validate_filename(name: object) -> str:
"""Whitelist the filename or raise 400.
Returning the validated string is convenient for direct
``base / _validate_filename(...)`` use at call sites.
"""
if not isinstance(name, str) or not _FILENAME_RE.match(name):
raise web.HTTPBadRequest(
reason=(
"Invalid filename. Allowed characters: letters, digits, "
"space, underscore, period, parentheses, hyphen. Must end "
"in .json."
)
)
return name
def _validate_dirname(name: str) -> str:
"""Whitelist a single-segment subfolder name or raise 400.
Same charset as filenames but without the `.json` extension. Used by
the autosave subfolder routing — a request with ``?dir=<x>`` lands its
file inside ``<library>/<x>/`` instead of the library root.
"""
if not isinstance(name, str) or not _DIRNAME_RE.match(name):
raise web.HTTPBadRequest(
reason=(
"Invalid dir. Allowed characters: letters, digits, space, "
"underscore, period, parentheses, hyphen. No path "
"separators, no extension."
)
)
return name
def _resolve_within_base(base: Path, name: str) -> Path:
"""Compute ``base / name`` and verify the resolved path stays under
``base`` after symlinks are followed. Defense-in-depth against a
symlink planted inside the configured library dir (plausible on a
facility shared mount with weak permissions) redirecting reads/writes
off-share.
Raises ``web.HTTPBadRequest`` on any escape (broken or hostile symlink).
Returns the joined path on success — note this is the un-resolved path
so the caller can still see the intended filename in error messages,
but the ``is_relative_to`` check has already been applied to the
resolved form.
"""
file_path = base / name
try:
resolved_file = file_path.resolve(strict=False)
resolved_base = base.resolve(strict=False)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not resolve preset path: {exc}"
) from exc
try:
resolved_file.relative_to(resolved_base)
except ValueError as exc:
raise web.HTTPBadRequest(
reason=(
"Preset path escapes the library directory (likely a symlink "
"planted in the library). Refusing to proceed."
)
) from exc
return file_path
def _resolve_target(lib_base: Path, subdir: str, name: str) -> tuple[Path, Path]:
"""Resolve ``lib_base[/subdir]/name`` for a preset operation, with the
symlink-escape check grounded at ``lib_base`` (NOT at the subdir) so a
hostile symlink inside an auto-save subfolder can't redirect writes
outside the library.
Returns ``(target_dir, file_path)`` — the caller may need ``target_dir``
to mkdir before writing. ``file_path`` is the un-resolved join, same as
``_resolve_within_base``.
Raises ``web.HTTPBadRequest`` on escape, ``web.HTTPInternalServerError``
on resolution failure.
"""
if subdir:
target_dir = lib_base / subdir
else:
target_dir = lib_base
file_path = target_dir / name
try:
resolved_file = file_path.resolve(strict=False)
resolved_lib = lib_base.resolve(strict=False)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not resolve preset path: {exc}"
) from exc
try:
resolved_file.relative_to(resolved_lib)
except ValueError as exc:
raise web.HTTPBadRequest(
reason=(
"Preset path escapes the library directory (likely a symlink "
"planted in the library or subfolder). Refusing to proceed."
)
) from exc
return target_dir, file_path
def _open_dir_in_file_manager(target_dir: Path) -> None:
"""Open ``target_dir`` in the OS file manager. Shared by the snapshot
and published-setup reveal routes. Raises ``web.HTTPNotFound`` when the
directory is absent and ``web.HTTPInternalServerError`` if the launcher
cannot start. Subprocess args are passed list-form (no shell), so a
controlled path can't trigger shell-metacharacter interpretation.
"""
if not target_dir.exists() or not target_dir.is_dir():
raise web.HTTPNotFound(reason=f"Path does not exist on disk: {target_dir}")
try:
if sys.platform == "darwin":
subprocess.Popen(["open", str(target_dir)])
elif sys.platform == "win32":
subprocess.Popen(["explorer.exe", str(target_dir)])
else:
subprocess.Popen(["xdg-open", str(target_dir)])
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not open path in file manager: {exc}"
) from exc
def register_routes(routes, setup_registry_factory=None, setup_runner_factory=None) -> None:
"""Attach the preset endpoints to the given aiohttp ``RouteTableDef``.
Called once from ``__init__.py`` at custom-node load time. Splitting
registration from the route handlers keeps this file unit-testable
without an aiohttp app fixture.
"""
if setup_registry_factory is None:
setup_registry_factory = _default_published_setup_registry
run_store = InMemorySetupRunStore()
def _default_setup_runner(request, registry: PublishedSetupRegistry) -> PublishedSetupRunner:
base_url = f"{request.scheme}://{request.host}"
return PublishedSetupRunner(registry, AiohttpComfyClient(base_url), run_store)
def _setup_runner(request, registry: PublishedSetupRegistry):
if setup_runner_factory is not None:
return setup_runner_factory()
return _default_setup_runner(request, registry)
def _log_setup_diagnostics(registry: PublishedSetupRegistry) -> None:
for diagnostic in registry.diagnostics:
print(f"[Koolook] published setup skipped: {diagnostic}")
def _web_asset_response(filename: str, content_type: str):
path = _WEB_DIR / filename
try:
return web.Response(
text=path.read_text(encoding="utf-8"),
content_type=content_type,
# These assets are dev-synced in place; without no-store the
# browser caches them and keeps running stale JS/HTML after an
# update, which looks like "the fix didn't land". Always revalidate.
headers={"Cache-Control": "no-store"},
)
except OSError as exc:
raise web.HTTPNotFound(reason=f"Koolook web asset '{filename}' not found.") from exc
@routes.get("/koolook/setup_runner_simulator.html")
async def setup_runner_simulator_html(_request):
return _web_asset_response("setup_runner_simulator.html", "text/html")
@routes.get("/koolook/setup_runner_simulator.js")
async def setup_runner_simulator_js(_request):
return _web_asset_response("setup_runner_simulator.js", "application/javascript")
@routes.get("/koolook/api/version")
async def get_koolook_version(_request):
return web.json_response(
{
"version": _read_pack_version(),
"releasesUrl": _GITHUB_RELEASES_URL,
"latestReleaseApiUrl": _GITHUB_LATEST_RELEASE_API_URL,
}
)
@routes.get("/koolook/api/setups")
async def list_published_setups(_request):
registry: PublishedSetupRegistry = setup_registry_factory()
rows = registry.listSetups()
_log_setup_diagnostics(registry)
return web.json_response(rows)
@routes.get("/koolook/api/setups/{setup_id}")
async def get_published_setup(request):
registry: PublishedSetupRegistry = setup_registry_factory()
setup_id = request.match_info["setup_id"]
setup = registry.getSetup(setup_id)
_log_setup_diagnostics(registry)
if setup is None:
raise web.HTTPNotFound(reason=f"Published setup '{setup_id}' not found.")
return web.json_response(setup)
@routes.post("/koolook/api/setups")
async def publish_setup(request):
try:
payload = await request.json()
except Exception as exc:
return web.json_response(
{"ok": False, "errors": [f"Body must be JSON: {exc}"]},
status=400,
)
if not isinstance(payload, dict):
return web.json_response(
{"ok": False, "errors": ["Body must be a JSON object."]},
status=400,
)
registry: PublishedSetupRegistry = setup_registry_factory()
result = registry.publishSetup(
visualGraph=payload.get("visualGraph"),
metadata=payload.get("metadata"),
inputContract=payload.get("inputContract"),
outputContract=payload.get("outputContract"),
source=payload.get("source"),
apiPrompt=payload.get("apiPrompt"),
)
if not result.valid:
return web.json_response(
{"ok": False, "errors": result.diagnostics},
status=400,
)
response = {"ok": True, "setup": result.setup}
storage_path = registry.storage_path
if storage_path is not None:
response["storagePath"] = str(storage_path)
return web.json_response(response)
@routes.post("/koolook/api/setups/{setup_id}/run")
async def run_published_setup(request):
try:
payload = await request.json()
except Exception as exc:
return web.json_response(
{"ok": False, "errors": [f"Body must be JSON: {exc}"]},
status=400,
)
if not isinstance(payload, dict):
return web.json_response(
{"ok": False, "errors": ["Body must be a JSON object."]},
status=400,
)
registry: PublishedSetupRegistry = setup_registry_factory()
runner = _setup_runner(request, registry)
try:
result = await runner.runSetup(
request.match_info["setup_id"],
payload.get("inputs", {}),
)
except SetupRunError as exc:
return web.json_response(
{"ok": False, "errors": exc.errors},
status=exc.status_code,
)
return web.json_response({"ok": True, "run": result})
@routes.get("/koolook/api/runs/{run_id}")
async def get_published_setup_run(request):
registry: PublishedSetupRegistry = setup_registry_factory()
runner = _setup_runner(request, registry)
try:
result = await runner.getRun(request.match_info["run_id"])
except SetupRunError as exc:
return web.json_response(
{"ok": False, "errors": exc.errors},
status=exc.status_code,
)
return web.json_response({"ok": True, "run": result})
@routes.get("/koolook/presets/info")
async def info(_request):
base, source = _configured_dir()
exists = base.exists() and base.is_dir()
writable = exists and os.access(str(base), os.W_OK)
return web.json_response(
{
"path": str(base),
"source": source, # "settings" | "env" | "default"
"isDefault": source == "default",
"envVar": ENV_VAR,
"exists": exists,
"writable": writable,
}
)
@routes.get("/koolook/presets/settings")
async def get_settings(_request):
"""Return the saved-in-UI library path (if any) plus the resolved
path the server actually uses right now. The UI's Settings dialog
renders the saved value as the editable field and the resolved
value as the read-only "currently in effect" line."""
settings = _read_settings()
saved = settings.get(SETTINGS_KEY_LIBRARY_PATH, "")
if not isinstance(saved, str):
saved = ""
resolved, source = _configured_dir()
return web.json_response(
{
"savedLibraryPath": saved,
"resolvedPath": str(resolved),
"source": source,
"envVar": ENV_VAR,
}
)
@routes.get("/koolook/presets/browse")
async def browse_dirs(request):
"""List child directories for the Settings dialog's path picker.
With ``?files=1`` also returns child ``*.json`` files — used by the
redesigned folder picker (issue #137, mockup section 6) to render a
greyed "this is the folder I expected" affordance below the
directories. Default response shape stays directory-only so any
existing callers (legacy Settings dialog) continue to work
unchanged.
The selected path is still saved through the existing settings
endpoint, so the library-path resolution and write checks stay
centralized.
"""
path = _browse_start_path(request.query.get("path", "").strip())
include_files = request.query.get("files", "").strip() in {"1", "true", "yes"}
try:
resolved = path.resolve(strict=False)
except OSError as exc:
raise web.HTTPBadRequest(reason=f"Could not resolve path: {exc}") from exc
if not resolved.exists() or not resolved.is_dir():
raise web.HTTPBadRequest(reason=f"Directory does not exist: {resolved}")
parent = resolved.parent if resolved.parent != resolved else None
roots = [{"name": str(root), "path": str(root)} for root in _browse_roots()]
try:
dirs = _list_child_dirs(resolved)
files = _list_child_files(resolved) if include_files else []
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not list directories: {exc}"
) from exc
body = {
"path": str(resolved),
"parentPath": str(parent) if parent else "",
"roots": roots,
"dirs": dirs,
}
if include_files:
body["files"] = files
return web.json_response(body)
@routes.post("/koolook/presets/browse/new-folder")
async def create_browse_dir(request):
"""Create one child folder under the current browse location.
Threat model: this picker is intentionally filesystem-wide so users
can choose a snapshot library outside the current Koolook library.
The route's boundary is the ComfyUI process OS user, not the preset
library root. We still reject multi-segment names and symlink escapes
relative to the selected parent so a request cannot turn "create this
child folder here" into "create something elsewhere."
"""
try:
payload = await request.json()
except Exception as exc:
raise web.HTTPBadRequest(reason=f"Body must be JSON: {exc}") from exc
if not isinstance(payload, dict):
raise web.HTTPBadRequest(reason="Body must be a JSON object.")
parent = _browse_start_path(str(payload.get("parentPath", "")).strip())
name = _validate_new_dir_name(payload.get("name", ""))
try:
resolved_parent = parent.resolve(strict=False)
except OSError as exc:
raise web.HTTPBadRequest(reason=f"Could not resolve parent: {exc}") from exc
if not resolved_parent.exists() or not resolved_parent.is_dir():
raise web.HTTPBadRequest(reason=f"Parent folder does not exist: {resolved_parent}")
child = resolved_parent / name
try:
resolved_child = child.resolve(strict=False)
except OSError as exc:
raise web.HTTPBadRequest(reason=f"Could not resolve new folder: {exc}") from exc
try:
resolved_child.relative_to(resolved_parent)
except ValueError as exc:
raise web.HTTPBadRequest(reason="New folder escapes the selected parent.") from exc
if resolved_child.exists():
raise web.HTTPBadRequest(reason=f"Folder already exists: {resolved_child}")
try:
child.mkdir()
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not create folder: {exc}"
) from exc
return web.json_response({"path": str(child)})
@routes.post("/koolook/presets/settings")
async def post_settings(request):
"""Persist a saved library path. Body is JSON ``{libraryPath: <str>}``.
Empty string or missing field clears the override (the server then
falls back to env-var or the built-in default).
"""
try:
payload = await request.json()
except Exception as exc:
raise web.HTTPBadRequest(
reason=f"Settings body must be JSON: {exc}"
) from exc
if not isinstance(payload, dict):
raise web.HTTPBadRequest(reason="Settings body must be a JSON object.")
new_path = payload.get(SETTINGS_KEY_LIBRARY_PATH, "")
if not isinstance(new_path, str):
raise web.HTTPBadRequest(
reason=f"`{SETTINGS_KEY_LIBRARY_PATH}` must be a string."
)
new_path = new_path.strip()
# Read-modify-write so unrelated keys (future settings) survive.
existing = _read_settings()
if new_path:
existing[SETTINGS_KEY_LIBRARY_PATH] = new_path
else:
# Empty string = clear the override; remove the key entirely
# so the resolution chain falls through cleanly to env / default.
existing.pop(SETTINGS_KEY_LIBRARY_PATH, None)
_write_settings(existing)
# Echo the new resolved state so the UI can update without a refetch.
resolved, source = _configured_dir()
return web.json_response(
{
"savedLibraryPath": new_path,
"resolvedPath": str(resolved),
"source": source,
}
)
@routes.get("/koolook/presets/list")
async def list_presets(request):
"""List preset files. With ``?dir=<x>`` lists files inside that
subfolder (used by autosave management); without dir, lists root
and HIDES anything matching ``_HIDDEN_LIST_PREFIXES`` (legacy flat
autosaves) plus any subdirectories (the per-preset autosave folders
— those should never appear in the user-facing Load list).
"""
base, _is_default = _configured_dir()
subdir_q = request.query.get("dir", "").strip()
if subdir_q:
_validate_dirname(subdir_q)
target_dir, _ = _resolve_target(base, subdir_q, "_listing.json")
target_dir = target_dir # named for clarity
else:
target_dir = base
if not target_dir.exists() or not target_dir.is_dir():
return web.json_response([])
out = []
try:
for entry in target_dir.iterdir():
if not entry.is_file() or not entry.name.lower().endswith(".json"):
continue
# Only filter legacy autosave flat files at the LIBRARY ROOT.
# Inside autosave subfolders we want to see everything (so
# the rotation pruner can list + delete its own files).
if not subdir_q and any(
entry.name.startswith(p) for p in _HIDDEN_LIST_PREFIXES
):
continue
try:
stat = entry.stat()
except OSError:
continue
row = {
"name": entry.name,
"mtime": stat.st_mtime,
"size": stat.st_size,
}
# Library-root rows only: scan the matching
# `<base>_autosave/` subfolder for the newest recovery file
# (``periodic.json`` and every ``pre_load_*.json``) and
# surface its mtime when strictly newer than the named
# file. Powers the Load dialog's inline "Newer auto-save
# available — restore?" affordance — the autosave UX needs
# the system to proactively offer the recovery copy when
# the user's last manual save is stale, rather than
# burying it inside the Recovery disclosure. Issue #137:
# before this we only considered ``periodic.json``, which
# missed cases where the user's most recent Load wrote a
# fresher ``pre_load_*.json`` snapshot. A handful of stats
# per row, no reads — still cheap on large libraries.
if not subdir_q:
base_name = entry.name[: -len(".json")]
autosave_dir = target_dir / f"{base_name}_autosave"
newest = _latest_autosave_mtime(autosave_dir, stat.st_mtime)
if newest is not None:
row["latestAutosaveMtime"] = newest
out.append(row)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not list preset library: {exc}"
) from exc
# Sort alphabetical (case-insensitive) at the server so every client
# sees the same order regardless of filesystem iteration order.
out.sort(key=lambda r: r["name"].lower())
return web.json_response(out)
@routes.get("/koolook/presets/autosaves/list")
async def list_autosaves(_request):
"""Walk every ``*_autosave/`` subdir (and ``_unsaved_autosave/``)
under the library and return a flat list of recovery snapshot
files. Powers the Load dialog's recovery section so a user can
restore from a pre-load or periodic auto-save without leaving
the UI.
Why a dedicated endpoint instead of recursing the existing
``list``? The user-facing list MUST hide subdirs (otherwise the
Load list gets cluttered with autosave files that aren't
directly user-restorable). This endpoint is the explicit "show
me everything in autosave folders" API — separated cleanly so
the boundary is grep-able and adding more subdir-scoped views
later (timeline / cross-preset comparison / etc.) doesn't have
to push more flags into the main list endpoint.
Sort: subdir alphabetical, then mtime descending within each
subdir (newest recovery point first — matches what users
expect when scanning for "the file just before I broke things").
"""
base, _ = _configured_dir()
if not base.exists() or not base.is_dir():
return web.json_response([])
out = []
try:
for subdir_entry in base.iterdir():
if not subdir_entry.is_dir():
continue
subdir_name = subdir_entry.name
# Match the autosave naming convention exactly so a user-
# created subdirectory called "my random folder" isn't
# treated as a recovery source.
is_unsaved = subdir_name == "_unsaved_autosave"
is_named = subdir_name.endswith("_autosave") and len(subdir_name) > len("_autosave")
if not (is_unsaved or is_named):
continue
for f in subdir_entry.iterdir():
if not f.is_file() or not f.name.lower().endswith(".json"):
continue
try:
stat = f.stat()
except OSError:
continue
out.append(
{
"dir": subdir_name,
"name": f.name,
"mtime": stat.st_mtime,
"size": stat.st_size,
}
)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not list autosave folders: {exc}"
) from exc
out.sort(key=lambda r: (r["dir"].lower(), -r["mtime"]))
return web.json_response(out)
@routes.get("/koolook/presets/file")
async def get_preset(request):
"""Serve preset JSON for ``?name=<name>[&dir=<subdir>]``; HEAD
short-circuits before the file read.
aiohttp's ``add_get`` auto-registers a HEAD route by default. We
can't opt out via ``allow_head=False`` because ComfyUI's
mirror-to-/api code in ``server.py`` blindly forwards
``RouteDef.kwargs`` into ``RouteTableDef.route(method, path)
(handler, **kwargs)`` whose inner closure rejects unknown
kwargs, crashing startup. So this single handler owns both
methods, branching on ``request.method`` so a ``presetExists``
HEAD probe doesn't have to read multi-MB JSON over
Dropbox/iCloud/NFS-mounted libraries just to answer 200/404.
"""
name = _validate_filename(request.query.get("name"))
subdir = request.query.get("dir", "").strip()
if subdir:
_validate_dirname(subdir)
base, _ = _configured_dir()
_, file_path = _resolve_target(base, subdir, name)
if not file_path.is_file():
raise web.HTTPNotFound(reason=f"Preset '{name}' not found.")
if request.method == "HEAD":
return web.Response(status=200)
try:
content = file_path.read_text(encoding="utf-8")
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not read preset: {exc}"
) from exc
return web.Response(text=content, content_type="application/json")
@routes.post("/koolook/presets/file")
async def post_preset(request):
name = _validate_filename(request.query.get("name"))
subdir = request.query.get("dir", "").strip()
if subdir:
_validate_dirname(subdir)
base, _ = _configured_dir()
if not base.parent.exists():
raise web.HTTPInternalServerError(
reason=(
f"Preset library parent directory does not exist: "
f"{base.parent}. Create it (or fix the {ENV_VAR} env "
f"var) and retry."
)
)
target_dir, file_path = _resolve_target(base, subdir, name)
try:
target_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not create preset directory: {exc}"
) from exc
body = await request.read()
# Atomic write — `tempfile.mkstemp` opens a uniquely-named temp
# file inside the already-validated `target_dir` using
# `O_CREAT | O_EXCL` semantics, then `os.replace` swaps it onto
# the final name. A crash or dropped connection mid-write can
# no longer truncate the existing good file: readers see either
# the old or the new content, never a half-written byte stream.
#
# Why mkstemp instead of a fixed `<file>.json.tmp` name? An
# attacker (or accidental user) with write access to the library
# could plant `<file>.json.tmp` as a symlink pointing outside
# the library. A naive `Path.write_bytes` would FOLLOW that
# symlink — past the path-traversal guard — and write the
# request body to wherever the symlink pointed, before
# `os.replace` swapped the symlink off. `mkstemp` defeats that
# in two ways: (1) `O_EXCL` aborts if anything (including a
# symlink) already exists at the chosen name, and (2) the name
# itself is random so the attacker can't pre-plant anything
# there. The `.tmp` suffix keeps the transient file invisible
# to `/koolook/presets/list` and `/koolook/presets/autosaves/list`
# (both filter to `.json`).
try:
tmp_fd, tmp_name = tempfile.mkstemp(
prefix=f"{file_path.stem}.",
suffix=".tmp",
dir=str(target_dir),
)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not create preset temp file: {exc}"
) from exc
try:
with os.fdopen(tmp_fd, "wb") as f:
f.write(body)
os.replace(tmp_name, str(file_path))
except OSError as exc:
try:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
except OSError:
pass
raise web.HTTPInternalServerError(
reason=f"Could not write preset: {exc}"
) from exc
return web.json_response({"ok": True, "name": name, "dir": subdir})
@routes.delete("/koolook/presets/file")
async def delete_preset(request):
name = _validate_filename(request.query.get("name"))
subdir = request.query.get("dir", "").strip()
if subdir:
_validate_dirname(subdir)
base, _ = _configured_dir()
_, file_path = _resolve_target(base, subdir, name)
if not file_path.is_file():
raise web.HTTPNotFound(reason=f"Preset '{name}' not found.")
try:
file_path.unlink()
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not delete preset: {exc}"
) from exc
return web.json_response({"ok": True, "name": name, "dir": subdir})
@routes.post("/koolook/presets/reveal")
async def reveal_preset_folder(request):
"""Open the preset library (or an autosave subfolder) in the OS
file manager. Optional ``?dir=<subdir>`` deep-links to a
per-preset autosave folder so the Recovery section can drop the
user inside the right `<preset>_autosave/` directory.
Path-traversal is grounded at the configured library base via
``_resolve_target``; subdir names go through ``_validate_dirname``.
Subprocess args are passed list-form (no shell), so a controlled
path can't trigger shell-metacharacter interpretation even if a
future check loosens the dirname charset.
"""
base, _ = _configured_dir()
subdir_q = request.query.get("dir", "").strip()
if subdir_q:
_validate_dirname(subdir_q)
target_dir, _ = _resolve_target(base, subdir_q, "_listing.json")
else:
target_dir = base
_open_dir_in_file_manager(target_dir)
return web.json_response({"ok": True, "path": str(target_dir)})
@routes.post("/koolook/api/setups/reveal")
async def reveal_published_setup_folder(_request):
"""Open the published-setups directory (where ``Publish setup``
writes ``setups.json``) in the OS file manager. Distinct from the
snapshot-library reveal above: the publish success card's Open
folder action must land in the registry folder, not the snapshot
library.
"""
registry: PublishedSetupRegistry = setup_registry_factory()
storage_path = registry.storage_path
if storage_path is None:
raise web.HTTPNotFound(
reason="Published setup storage path is unavailable."
)
target_dir = storage_path.parent
_open_dir_in_file_manager(target_dir)
return web.json_response({"ok": True, "path": str(target_dir)})
def install() -> bool:
"""Hook the routes into ComfyUI's running PromptServer.
Returns ``True`` on success, ``False`` if PromptServer wasn't reachable
(very early import, headless test, etc.). The caller logs the failure
so the rest of Koolook still loads — the snapshot feature just won't
work in that session.
"""
try:
from server import PromptServer # type: ignore[import-not-found]
except Exception:
return False
try:
register_routes(PromptServer.instance.routes)
except Exception as exc: # pragma: no cover - defensive
print(f"[Koolook] failed to register preset routes: {exc}")
return False
return True