The registry scan report on 1.1.4 listed exactly three findings, all of them the folder-dialog process launches in __init__.py (yara rule python_command_injection_risk, severity "info"), and no path-traversal findings at all: the 1.1.4 containment guard did its job. An admin escalated 1.1.2, 1.1.3 and 1.1.4 from Flagged to Banned on those three hits anyway. The pattern itself is not against the rules (Pixaroma ships an identical PowerShell/osascript picker and stays listed), but arguing that point does not make the package installable, so the surface is gone instead: - __init__.py registers no HTTP route and launches no process. The Browse button and POST /craftkit/browse_folder are removed. - Folder approval moved to ComfyUI's own settings store under CraftKit.AllowedFolders. The node button writes it through the frontend settings API; Python only ever reads the file. - craftkit_folder_guard.py drops remember_folder/dialog_available and the allow_any flag, so containment can no longer be disabled at all. The legacy allowed_folders.json is still read so folders approved with the old dialog keep working. - Comments avoid naming the module and call form the scanner matches, since the scan reads source text. Verified against a simulated install (10/10 cases) and end to end in ComfyUI: button approves, Python accepts the folder and its subfolders, pre-update approvals still work, system folders and .. traversal still refused.
223 lines
9.4 KiB
JavaScript
223 lines
9.4 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs?v=3";
|
|
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs?v=6";
|
|
import { setWidgetVisible } from "./shared/widget_visibility.mjs?v=2";
|
|
|
|
// Approved folders live in ComfyUI's own settings store, so approving one is a
|
|
// plain settings write from the frontend: no CraftKit route, no helper process.
|
|
// The id must stay identical to SETTING_KEY in craftkit_folder_guard.py.
|
|
const SETTING_ID = "CraftKit.AllowedFolders";
|
|
|
|
function getSetting(id) {
|
|
try {
|
|
if (app.extensionManager?.setting?.get) return app.extensionManager.setting.get(id);
|
|
} catch (e) { /* older frontend, fall through */ }
|
|
try {
|
|
return app.ui?.settings?.getSettingValue?.(id);
|
|
} catch (e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function setSetting(id, value) {
|
|
if (app.extensionManager?.setting?.set) return await app.extensionManager.setting.set(id, value);
|
|
return await app.ui.settings.setSettingValue(id, value);
|
|
}
|
|
|
|
// ';' is the separator (no path contains one); newlines are accepted for
|
|
// anyone who pastes a list into the settings field by hand. Mirrors
|
|
// _split_folders() in craftkit_folder_guard.py.
|
|
function splitFolders(value) {
|
|
const parts = Array.isArray(value)
|
|
? value
|
|
: String(value ?? "").replace(/\r/g, "\n").replace(/;/g, "\n").split("\n");
|
|
return parts.map(cleanPath).filter(Boolean);
|
|
}
|
|
|
|
function cleanPath(p) {
|
|
let s = String(p ?? "").trim().replace(/^["']+|["']+$/g, "").trim();
|
|
// Strip a trailing separator, but never turn "D:/" into "D:".
|
|
while (s.length > 3 && (s.endsWith("/") || s.endsWith("\\"))) s = s.slice(0, -1);
|
|
return s;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Craftopia.SmartBatchResize",
|
|
|
|
settings: [
|
|
{
|
|
id: SETTING_ID,
|
|
category: ["CraftKit", "Folders", "Approved folders"],
|
|
name: "Approved folders",
|
|
tooltip:
|
|
"Folders Smart Batch Resize is allowed to read and write, separated by ';'. " +
|
|
"Subfolders are included. Use the 'Approve folder' button on the node to add one. " +
|
|
"ComfyUI's own input/output/temp folders always work without being listed here.",
|
|
type: "text",
|
|
defaultValue: "",
|
|
},
|
|
],
|
|
|
|
async nodeCreated(node) {
|
|
if (node.comfyClass !== "SmartBatchResize") return;
|
|
|
|
const folderWidget = node.widgets?.find(w => w.name === "input_folder");
|
|
if (!folderWidget) return;
|
|
|
|
const APPROVE_LABEL = "✅ Approve folder";
|
|
const btn = node.addWidget("button", APPROVE_LABEL, null, async () => {
|
|
const flash = (label) => {
|
|
btn.label = label;
|
|
btn.name = label;
|
|
node.setDirtyCanvas(true);
|
|
setTimeout(() => {
|
|
btn.label = APPROVE_LABEL;
|
|
btn.name = APPROVE_LABEL;
|
|
node.setDirtyCanvas(true);
|
|
}, 1800);
|
|
};
|
|
|
|
const folder = cleanPath(folderWidget.value);
|
|
if (!folder) {
|
|
flash("⚠ Paste a path first");
|
|
return;
|
|
}
|
|
try {
|
|
const existing = splitFolders(getSetting(SETTING_ID));
|
|
const known = existing.some(f => f.toLowerCase() === folder.toLowerCase());
|
|
if (known) {
|
|
flash("✔ Already approved");
|
|
return;
|
|
}
|
|
await setSetting(SETTING_ID, [...existing, folder].join(";"));
|
|
flash("✔ Folder approved");
|
|
} catch (e) {
|
|
console.error("[SmartBatchResize] Could not approve folder:", e);
|
|
flash("⚠ Approve failed (see console)");
|
|
}
|
|
}, { serialize: false });
|
|
|
|
btn.serialize = false;
|
|
|
|
// Move Approve button to right after input_folder (index 1)
|
|
const folderIdx = node.widgets.indexOf(folderWidget);
|
|
const btnIdx = node.widgets.indexOf(btn);
|
|
if (btnIdx !== folderIdx + 1) {
|
|
node.widgets.splice(btnIdx, 1);
|
|
node.widgets.splice(folderIdx + 1, 0, btn);
|
|
}
|
|
|
|
// Compact preset chips for longest_side.
|
|
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
|
|
if (longestSideWidget) {
|
|
const PRESETS = [512, 768, 1024, 1536];
|
|
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
|
|
|
// Move preset row to right after longest_side
|
|
const lsIdx = node.widgets.indexOf(longestSideWidget);
|
|
const pwIdx = node.widgets.indexOf(presetWidget);
|
|
if (pwIdx !== lsIdx + 1) {
|
|
node.widgets.splice(pwIdx, 1);
|
|
node.widgets.splice(lsIdx + 1, 0, presetWidget);
|
|
}
|
|
}
|
|
|
|
// Section-header dividers — visual only, not serialized. Standalone node
|
|
// with a lot of inputs, so grouping them keeps it scannable at a glance.
|
|
const addSectionDivider = (beforeWidgetName, label) => {
|
|
const target = node.widgets.find(w => w.name === beforeWidgetName);
|
|
if (!target) return;
|
|
const div = createDividerWidget(label);
|
|
node.addCustomWidget(div);
|
|
const targetIdx = node.widgets.indexOf(target);
|
|
const divIdx = node.widgets.indexOf(div);
|
|
node.widgets.splice(divIdx, 1);
|
|
node.widgets.splice(targetIdx, 0, div);
|
|
};
|
|
addSectionDivider("longest_side", "RESIZE");
|
|
addSectionDivider("prefix", "FILENAME");
|
|
addSectionDivider("folder_resolution", "OUTPUT LOCATION");
|
|
addSectionDivider("skip_if_exists", "OPTIONS");
|
|
addSectionDivider("output_format", "OUTPUT FORMAT");
|
|
|
|
// Override serialize so non-serializable JS widgets (Approve, presets, status,
|
|
// run batch) are excluded from widgets_values in the saved workflow JSON.
|
|
// Without this, LiteGraph saves null slots for these widgets which then shift
|
|
// all Python widget values on load (configure runs before nodeCreated).
|
|
// Invariant: Python widgets must stay in their original relative order in
|
|
// this.widgets — JS widgets may be spliced in between, but never reorder
|
|
// the Python ones, or the positional value assignment on load will silently
|
|
// misalign.
|
|
const origSerialize = node.serialize;
|
|
node.serialize = function () {
|
|
const data = origSerialize.call(this);
|
|
if (data.widgets_values) {
|
|
data.widgets_values = this.widgets
|
|
.filter(w => w.serialize !== false && w.options?.serialize !== false)
|
|
.map(w => w.value);
|
|
}
|
|
return data;
|
|
};
|
|
|
|
// Canvas-drawn status display
|
|
const statusWidget = createStatusWidget("— RUN TO PROCESS —");
|
|
node.addCustomWidget(statusWidget);
|
|
|
|
// Run Batch button
|
|
const runBtn = node.addWidget("button", "▶ Run Batch", null, () => {
|
|
app.queuePrompt(0, 1).catch(e => console.error("[SmartBatchResize] Queue failed:", e));
|
|
}, { serialize: false });
|
|
runBtn.serialize = false;
|
|
|
|
// Show/hide counter_start based on use_counter toggle
|
|
const useCounterWidget = node.widgets?.find(w => w.name === "use_counter");
|
|
const counterStartWidget = node.widgets?.find(w => w.name === "counter_start");
|
|
if (useCounterWidget && counterStartWidget) {
|
|
const updateCounterVisibility = () => {
|
|
setWidgetVisible(node, counterStartWidget, useCounterWidget.value);
|
|
};
|
|
const origCallback = useCounterWidget.callback;
|
|
useCounterWidget.callback = function (...args) {
|
|
origCallback?.call(this, ...args);
|
|
updateCounterVisibility();
|
|
};
|
|
const origOnConfigure = node.onConfigure;
|
|
node.onConfigure = function (...args) {
|
|
origOnConfigure?.call(this, ...args);
|
|
updateCounterVisibility();
|
|
};
|
|
updateCounterVisibility();
|
|
}
|
|
|
|
// Hide any customtext widget already present (e.g. from a previously run workflow)
|
|
const hideCustomText = () => {
|
|
for (const w of node.widgets) {
|
|
if (w.type === "customtext") setWidgetVisible(node, w, false);
|
|
}
|
|
};
|
|
hideCustomText();
|
|
|
|
// Intercept onExecuted to update status and hide the default customtext output
|
|
const origOnExecuted = node.onExecuted;
|
|
node.onExecuted = function (output) {
|
|
origOnExecuted?.call(this, output);
|
|
if (output?.text?.[0]) {
|
|
statusWidget._text = output.text[0];
|
|
hideCustomText();
|
|
// setDirtyCanvas alone doesn't repaint Nodes 2.0's canvas bridge —
|
|
// see the comment in preset_picker_widget.mjs.
|
|
statusWidget.triggerDraw?.();
|
|
}
|
|
node.setDirtyCanvas(true);
|
|
};
|
|
|
|
// Force a full size/layout recompute now that every widget has been
|
|
// added and repositioned. Without this, the preset-chip row's very
|
|
// first paint can use a stale/undersized widgetWidth (from before the
|
|
// node's final layout settled), rendering uneven or clipped chips
|
|
// until the next unrelated redraw fixes it.
|
|
node.setSize(node.computeSize());
|
|
node.setDirtyCanvas(true, true);
|
|
},
|
|
});
|