Critical security fixes ----------------------- - Sandbox Jinja2 in template_render + prompt_template_render (was RCE via shared workflows) - Drop user home dir from filesystem allow-list (was arbitrary delete/overwrite) - Confine save_image_with_manifest + sanitize filename basename - SSRF block on http_get/http_post/notify_webhook (catches 169.254.169.254 metadata) - Case-insensitive path confinement on Windows Critical correctness/perf ------------------------- - halftone_dots: O(N^2) -> O(cells*36); was freezing worker on 1024+ images - Preserve latent dict keys (noise_mask, batch_index) in latent_math/noise/upscale - Preserve alpha channel in invert + threshold_binary on RGBA inputs - Force odd kernel in mask_erode/mask_dilate to prevent H+1 shape mismatch - Snap aspect_ratio_pick output to multiples of 8 (was crashing SD UNet) Theme engine (JS) ----------------- - Hoist window.matchMedia() out of per-frame hot path -> recovers 200 FPS from 25 - Wrap-once draw method to stop stomping other extensions wrappers - nodeRemoved hook + WeakMap groups + in-place canvas reuse -> three memory leaks closed - Wire thickness now uses connections_width (the wrap was a no-op vs LiteGraph) - onGroupAdded auto-themes new groups; budget change triggers redraw - Gradient caching in synthwave/glassmorphic New ops (10) ------------ - json_path (programming): dotted/bracketed path extract - latent_nan_guard, latent_pad_crop (mask_latent) - mask_from_depth, mask_invert (mask_latent) - color_match_histogram, image_composite_over (image_pro/value) - frequency_separation (image_pro/style) - pad_to_multiple (image_pro/spatial) - save_images_zip (io_workflow) - llm_enhance_prompt via Ollama / LM Studio / llama.cpp (prompt) Theme UX -------- - Right-click "Theme..." submenu on every node (including vanilla ComfyUI nodes) - Auto-contrast title text (WCAG AA-based flip) Tests: 373 Python + 33 JS = 406 passing (added 33 new tests for security + features). Full review notes in CHANGELOG.md.
198 lines
5.4 KiB
Python
198 lines
5.4 KiB
Python
"""Text processing, regex, templating, parsing."""
|
|
|
|
import json
|
|
import re
|
|
|
|
import yaml
|
|
from jinja2 import StrictUndefined
|
|
from jinja2.sandbox import SandboxedEnvironment
|
|
|
|
from .. import op
|
|
|
|
|
|
# SandboxedEnvironment blocks attribute-walks like
|
|
# `{{ obj.__class__.__bases__[0].__subclasses__() }}` that would otherwise let a
|
|
# shared workflow pivot from string templating into arbitrary Python exec/RCE.
|
|
_jinja_env = SandboxedEnvironment(
|
|
autoescape=False,
|
|
undefined=StrictUndefined,
|
|
keep_trailing_newline=False,
|
|
)
|
|
|
|
|
|
@op(
|
|
op_id="regex_match",
|
|
display_name="Regex Match",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"text": ("STRING", {"default": ""}),
|
|
"pattern": ("STRING", {"default": ""}),
|
|
}},
|
|
output_indices=(3,),
|
|
description="True if `pattern` matches anywhere in `text`.",
|
|
)
|
|
def regex_match(self, text, pattern):
|
|
return (re.search(pattern, text) is not None,)
|
|
|
|
|
|
@op(
|
|
op_id="regex_extract",
|
|
display_name="Regex Extract",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"text": ("STRING", {"default": ""}),
|
|
"pattern": ("STRING", {"default": ""}),
|
|
"group": ("INT", {"default": 0, "min": 0, "max": 99}),
|
|
}},
|
|
output_indices=(0,),
|
|
description="Returns the matched group, or '' if no match.",
|
|
)
|
|
def regex_extract(self, text, pattern, group=0):
|
|
m = re.search(pattern, text)
|
|
if m is None:
|
|
return ("",)
|
|
if group > 0 and group > (m.lastindex or 0):
|
|
return ("",)
|
|
return (m.group(group),)
|
|
|
|
|
|
@op(
|
|
op_id="regex_replace",
|
|
display_name="Regex Replace",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"text": ("STRING", {"default": ""}),
|
|
"pattern": ("STRING", {"default": ""}),
|
|
"replacement": ("STRING", {"default": ""}),
|
|
}},
|
|
output_indices=(0,),
|
|
)
|
|
def regex_replace(self, text, pattern, replacement):
|
|
return (re.sub(pattern, replacement, text),)
|
|
|
|
|
|
@op(
|
|
op_id="regex_split",
|
|
display_name="Regex Split",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"text": ("STRING", {"default": ""}),
|
|
"pattern": ("STRING", {"default": r"\s+"}),
|
|
"max_splits": ("INT", {"default": 0, "min": 0, "max": 1024}),
|
|
}},
|
|
output_indices=(4,),
|
|
description="max_splits = 0 means unlimited.",
|
|
)
|
|
def regex_split(self, text, pattern, max_splits=0):
|
|
return (re.split(pattern, text, maxsplit=max_splits),)
|
|
|
|
|
|
@op(
|
|
op_id="template_render",
|
|
display_name="Template Render (Jinja2)",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"template": ("STRING", {"default": "", "multiline": True}),
|
|
"vars_json": ("STRING", {"default": "{}"}),
|
|
}},
|
|
output_indices=(0,),
|
|
description="Render Jinja2 template with vars from JSON.",
|
|
)
|
|
def template_render(self, template, vars_json="{}"):
|
|
variables = json.loads(vars_json) if vars_json else {}
|
|
if not isinstance(variables, dict):
|
|
raise ValueError("vars_json must decode to an object")
|
|
return (_jinja_env.from_string(template).render(**variables),)
|
|
|
|
|
|
@op(
|
|
op_id="string_concat",
|
|
display_name="String Concat",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {
|
|
"a": ("STRING", {"default": ""}),
|
|
"b": ("STRING", {"default": ""}),
|
|
"sep": ("STRING", {"default": ""}),
|
|
}},
|
|
output_indices=(0,),
|
|
)
|
|
def string_concat(self, a, b, sep=""):
|
|
return (f"{a}{sep}{b}",)
|
|
|
|
|
|
@op(
|
|
op_id="string_upper",
|
|
display_name="String Upper",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"text": ("STRING", {"default": ""})}},
|
|
output_indices=(0,),
|
|
)
|
|
def string_upper(self, text):
|
|
return (text.upper(),)
|
|
|
|
|
|
@op(
|
|
op_id="string_lower",
|
|
display_name="String Lower",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"text": ("STRING", {"default": ""})}},
|
|
output_indices=(0,),
|
|
)
|
|
def string_lower(self, text):
|
|
return (text.lower(),)
|
|
|
|
|
|
@op(
|
|
op_id="string_trim",
|
|
display_name="String Trim",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"text": ("STRING", {"default": ""})}},
|
|
output_indices=(0,),
|
|
)
|
|
def string_trim(self, text):
|
|
return (text.strip(),)
|
|
|
|
|
|
@op(
|
|
op_id="tokenize_words",
|
|
display_name="Tokenize Words",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"text": ("STRING", {"default": ""})}},
|
|
output_indices=(4,),
|
|
description="Naive whitespace split.",
|
|
)
|
|
def tokenize_words(self, text):
|
|
return (text.split(),)
|
|
|
|
|
|
@op(
|
|
op_id="yaml_parse",
|
|
display_name="YAML Parse",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"text": ("STRING", {"default": "", "multiline": True})}},
|
|
output_indices=(5,),
|
|
description="YAML → dict. Empty input returns empty dict. Top-level non-mapping is wrapped under 'value'.",
|
|
)
|
|
def yaml_parse(self, text):
|
|
if not text.strip():
|
|
return ({},)
|
|
parsed = yaml.safe_load(text)
|
|
if parsed is None:
|
|
return ({},)
|
|
if isinstance(parsed, dict):
|
|
return (parsed,)
|
|
return ({"value": parsed},)
|
|
|
|
|
|
@op(
|
|
op_id="yaml_dump",
|
|
display_name="YAML Dump",
|
|
category="Text & Parsing",
|
|
input_schema={"required": {"data_json": ("STRING", {"default": "{}", "multiline": True})}},
|
|
output_indices=(0,),
|
|
description="JSON → YAML.",
|
|
)
|
|
def yaml_dump(self, data_json="{}"):
|
|
data = json.loads(data_json) if data_json else {}
|
|
return (yaml.safe_dump(data, sort_keys=False, default_flow_style=False).rstrip("\n") + "\n",)
|