Files
malkuthro-ComfyUI-Koolook/koolook_routes.py
T
malkuthroandClaude Opus 4.7 ea4ee071e7 feat(snapshot): YES/NO modal on newer auto-save + 24h timestamps (#111)
* feat(snapshot): autosave UX — YES/NO modal when newer + 24h local timestamps

Two snapshot-UX threads in one slice:

1. **24-hour timestamps with relative-recent tier** in the Load dialog
   and snapshot status tooltip. The previous `en-US` 12-hour format
   produced "12:56 AM" for timestamps just past midnight, which read
   visually like noon. Now: `just now` / `12 min ago` / `today 14:32` /
   `yesterday 23:58` / `May 6 14:32`. Centralized in a new
   `format_time.js` helper so future surfaces stay aligned.

2. **YES/NO choice when an auto-save is newer than the saved version.**
   Click any preset row in Snapshot → Load and, if the matching
   `<preset>_autosave/periodic.json` is more recent than the named
   save's mtime, the confirm dialog upgrades from "Load preset" to a
   YES/NO question with both timestamps shown side-by-side. YES loads
   the auto-save, NO loads the older deliberate save (the row clicked),
   Esc cancels. Standard pre-load auto-save protection still runs on
   either path. Server-side `/koolook/presets/list` augments root rows
   with `latestAutosaveMtime` via one extra `stat()` per row — no file
   read, cheap on large libraries. Aligns the system with the user's
   mental model: named saves stay deliberate (only updated on Save /
   Quick Save), and the auto-save is treated as a recovery copy the
   system surfaces at decision time rather than a parallel save the
   user has to remember to check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(snapshot): restore-from-auto-save baselines as "auto-saved", not "saved"

`doAutosaveRestore` was calling `markStateSaved()` after a successful
restore, which baselined the *named-save* fingerprint to the auto-save's
content. The dot turned green even though `<preset>.json` on disk still
held the OLDER deliberate save — blurring the deliberate-save model the
Path C UX deliberately reinforces (named saves only change on Save /
Quick Save).

New `markStateAutosaved()` export in `snapshot.js` baselines the
*periodic* fingerprint instead, so the dot reads "auto-saved" (blue)
until the user explicitly commits the restored state via Quick Save or
Save. Threaded through `tree.js` → `showLoadSnapshotDialog` via the
existing dependency-injection pattern. Toast message also updated to
nudge: `Restored auto-save of "Foo" — Quick Save to commit to the
named file.`

Fixes the regression flagged on the PR #111 review. Applies to BOTH
restore call sites (the new YES path AND the long-existing Recovery
disclosure) since they share the helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:20:26 +02:00

789 lines
32 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 string
import subprocess
import sys
import tempfile
from pathlib import Path
from aiohttp import web
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_",)
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 _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 _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 register_routes(routes) -> 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.
"""
@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.
This intentionally returns directory names only, not files. 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())
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)
except OSError as exc:
raise web.HTTPInternalServerError(
reason=f"Could not list directories: {exc}"
) from exc
return web.json_response(
{
"path": str(resolved),
"parentPath": str(parent) if parent else "",
"roots": roots,
"dirs": dirs,
}
)
@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: stat the matching `<base>_autosave/
# periodic.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.
# One extra stat per row, no read — cheap even on large
# libraries.
if not subdir_q:
base_name = entry.name[: -len(".json")]
auto_path = target_dir / f"{base_name}_autosave" / "periodic.json"
try:
a_stat = auto_path.stat()
if a_stat.st_mtime > stat.st_mtime:
row["latestAutosaveMtime"] = a_stat.st_mtime
except OSError:
pass
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
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
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