* feat(system): report configured, effective and watched sources
GET /prompt_manager/settings adds settings.monitoring_enabled, a sources
block (configured roots with valid/code/reason, effective roots with their
listing index and watch state, auto_detected) and the monitor state
(present, observer_alive, scheduled). Every path is a public path.
A source is "watching" only while a live observer scheduled it, "manual"
when monitoring is off or the process has no monitor, and otherwise
"restart_pending".
POST accepts monitoring_enabled as a strict bool, saved under
gallery.monitoring.enabled; restart_required_fields names it when the
running observer does not already match. A save that changes the sources
runs as a "Source change" records hold, so it is refused with 409 busy
while a scan runs. Case variants of one folder are saved once.
POST /prompt_manager/settings/validate-source {path} checks a path
lexically (no "..", no control characters, native path forms only) and
then against the gallery-root rules, answering a code and reason, and
writes nothing.
* fix(system): no existence oracle in source validation
A gallery root's containment in the allowed ComfyUI folders is now
decided before its existence and type, so a path outside them answers
"outside-allowed" whether or not it exists. validate-source also folds
the filesystem-root and home-directory codes into "outside-allowed", with
one reason, and logs the detail server-side only. Paths inside the
allowed folders still say "not-found" or "not-directory". The legacy
settings save shares the same check.
* feat(scan): scan one selected source
POST /prompt_manager/jobs/scan accepts roots: indexes into the effective
sources, strict non-negative ints (no bools or floats), deduplicated, at
most 64, non-empty when present. An index past the sources is a 400
"unknown-source" and starts nothing. The job walks only the selected
sources, resolved when it was requested, and keeps each file's listing
root_index. Its scope records files, roots (null for every source) and
output_folders, the number of folders walked, which the log's first line
counts. The legacy /scan route still scans every source, and attaching to
a scoped job keeps its payload keys.
* feat(system): manage sources and start scans natively
System > Sources & scans renders the concept's section on the server's
sources: one row per effective source with its watch state and a scan
scoped to that source, the 3.2.0 update notice with the legacy dismissal,
Add source checked by validate-source, Remove, monitoring and worker
threads as host drafts behind the one Save row, and Refresh files.
A save refused by a running scan names the job. Thumbnail generation
hands off to the legacy gallery until S5.
Deviations D98, D113 and D-NEW-S4-1 are recorded in TESTING.md.
* fix(security): decide source containment as text before any filesystem access
Every caller (validate-source, POST /settings, config.json at load) now
refuses a source through one check, sources.source_problem: the form
check first, then GalleryConfig.gallery_root_problem, which compares the
normalised spelling with the allowed folders before anything resolves,
stats or opens the path. A UNC share, the \\?\ and \\.\ namespaces, a
device or any outside folder is refused with no SMB request, no
executor stall and no existence or timing oracle; only then is the path
resolved and contained again, which still catches a symlink out. The
filesystem root and the home directory answer outside-allowed with the
same reason as any outside path, so no caller learns the home path.
Also:
- POST /settings runs the form checks and parses the roots off the loop;
an unusable source is a coded 400 invalid-source naming its public
path (as text) and its index.
- ComfyUI folders outside the base directory are path anchors, so their
sources round-trip and never collide by folder name.
- A per-source scan sends each index with its public path; a stale or
shifted index is 409 sources-changed and starts nothing.
- A source still watched after monitoring was switched off is
"stopping".
- Roots are compared as folders, not spellings, so an unchanged legacy
resend takes no source-change hold.
- Tests: no realpath/stat/lstat of an outside path (native and Windows
forms with ntpath fakes), the alike answers with /, ~ and a link to ~,
an event instead of a timer wait, and a teardown that drains jobs
before the temporary database goes.
* fix(system): say what Sources saved, refused and still watches
- A per-source scan sends the source's public path; when the sources
changed since they were read the toast says the server's reason and
the list and saved values are read again.
- "watching until restart" for a source the running monitor still
watches after monitoring was saved off, and the status adds "Restart
ComfyUI to apply: Watch for new outputs." from restart_required_fields.
- A save refused for one unusable source marks that row and the status
is the server's message naming it.
- A valid configured source the effective list holds under another
spelling reads "same folder as another source".
- The busy toast names the job the 409 named (ApiError keeps a
validated running job and refused source), falling back to the list.
- The Add source dialog's listeners live on the page scope and Enter
adds; its markup is compared with the concept (D127).
- TESTING.md: D-NEW-S4-1 is D127; D-NEW-S4-2 records the new copy; the
Sources contract describes the lexical containment, invalid-source and
sources-changed.
* fix(security): guard the Windows form probes on a token of their own
The no-filesystem guard matched "C:", which on a Windows runner is also
the temporary ComfyUI tree the save may resolve for its trusted first
entry. The probe paths now carry a token no trusted directory holds.
* docs(system): number the Sources states deviation D128
156 lines
7.6 KiB
JavaScript
156 lines
7.6 KiB
JavaScript
/**
|
|
* Small JSON API client for the admin and gallery pages.
|
|
*
|
|
* Every method resolves with the parsed body of a successful response and
|
|
* throws ApiError(status, message) for a non-2xx status, a `success: false`
|
|
* body or a body that is not JSON, so callers have one failure path.
|
|
*
|
|
* latestOnly() guards list requests against out-of-order responses: each new
|
|
* call aborts the previous in-flight request and a superseded call resolves
|
|
* to STALE instead of its (outdated) result.
|
|
*
|
|
* Loadable in the browser (window.ApiClient) and in Node for tests.
|
|
*/
|
|
(function (root) {
|
|
"use strict";
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message, code = null, recovery = null, current = null) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
this.code = typeof code === "string" && /^[a-z0-9-]{1,64}$/.test(code) ? code : null;
|
|
this.recovery = null;
|
|
if (recovery && typeof recovery === "object" && !Array.isArray(recovery)) {
|
|
const keys = Object.keys(recovery);
|
|
if (keys.length === 1 && keys[0] === "action" &&
|
|
["refresh-history", "reload-outputs", "retry-read"].includes(recovery.action)) {
|
|
this.recovery = { action: recovery.action };
|
|
} else if (keys.length === 2 && keys.includes("action") && keys.includes("limit") &&
|
|
recovery.action === "smaller-limit" && Number.isInteger(recovery.limit) &&
|
|
recovery.limit >= 1 && recovery.limit <= 100) {
|
|
this.recovery = { action: "smaller-limit", limit: recovery.limit };
|
|
}
|
|
}
|
|
// A tag-conflict answer carries the image's current tag set; only a
|
|
// well-formed one is kept, copied, so the dialog can show it.
|
|
this.current = null;
|
|
if (current && Array.isArray(current.tags) && current.tags.length <= 100 &&
|
|
current.tags.every(tag => typeof tag === "string") &&
|
|
Number.isSafeInteger(current.tag_revision) && current.tag_revision >= 0) {
|
|
this.current = { tags: current.tags.slice(), tag_revision: current.tag_revision };
|
|
}
|
|
// A busy answer names the running job that holds the slot; an
|
|
// invalid-source answer names the source it refused. Only
|
|
// well-formed values are kept.
|
|
this.running = null;
|
|
const running = current && current.running;
|
|
if (this.code === "busy" && running && typeof running.id === "string" &&
|
|
/^[A-Za-z0-9_-]{1,64}$/.test(running.id) && typeof running.title === "string" &&
|
|
running.title.length > 0 && running.title.length <= 200) {
|
|
this.running = { id: running.id, title: running.title };
|
|
}
|
|
this.source = null;
|
|
if (this.code === "invalid-source" && current && typeof current.source === "string" &&
|
|
current.source.length > 0 && current.source.length <= 4096) {
|
|
this.source = current.source;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Result of a latestOnly() call that was superseded by a newer one. */
|
|
const STALE = Symbol("stale response");
|
|
|
|
/** Keep Prompt Manager requests under the prefix that serves its page. */
|
|
function resolveUrl(url, pathname = root.location && root.location.pathname) {
|
|
if (typeof url !== "string" || !url.startsWith("/prompt_manager/")) return url;
|
|
const match = (pathname || "").match(/^(.*)\/prompt_manager(?:\/|$)/);
|
|
return match && match[1] ? match[1] + url : url;
|
|
}
|
|
|
|
/** Like fetch(), but keep Prompt Manager URLs under the page's proxy mount. */
|
|
function fetchUrl(url, init) {
|
|
return globalThis.fetch(resolveUrl(url), init);
|
|
}
|
|
|
|
/**
|
|
* @param {typeof fetch} [fetchImpl] - defaults to the global fetch (bound to globalThis)
|
|
*/
|
|
function createApiClient(fetchImpl) {
|
|
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
|
|
|
|
async function request(method, url, { body, signal, cache, keepalive } = {}) {
|
|
const init = { method, headers: {} };
|
|
if (cache !== undefined) init.cache = cache;
|
|
// keepalive lets a write or a release finish while its page unloads (pagehide).
|
|
if (keepalive) init.keepalive = true;
|
|
if (body !== undefined) {
|
|
init.headers["Content-Type"] = "application/json";
|
|
init.body = JSON.stringify(body);
|
|
}
|
|
if (signal) init.signal = signal;
|
|
|
|
const response = await doFetch(resolveUrl(url), init);
|
|
let data = null;
|
|
let parsed = true;
|
|
try {
|
|
data = await response.json();
|
|
} catch (_) {
|
|
parsed = false;
|
|
}
|
|
|
|
const serverMessage = data && typeof data === "object" ? data.error || data.message : undefined;
|
|
if (!response.ok) {
|
|
throw new ApiError(response.status, serverMessage || response.statusText || `HTTP ${response.status}`, data?.code, data?.recovery, data);
|
|
}
|
|
if (!parsed) throw new ApiError(response.status, "Invalid JSON response");
|
|
if (data && typeof data === "object" && data.success === false) {
|
|
throw new ApiError(response.status, serverMessage || "Request failed", data.code, data.recovery, data);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
return {
|
|
// cache is a fetch RequestCache mode such as "no-store"; omitted keeps the default.
|
|
get: (url, { signal, cache } = {}) => request("GET", url, { signal, cache }),
|
|
post: (url, body, { signal, keepalive } = {}) => request("POST", url, { body, signal, keepalive }),
|
|
put: (url, body, { signal, keepalive } = {}) => request("PUT", url, { body, signal, keepalive }),
|
|
patch: (url, body, { signal } = {}) => request("PATCH", url, { body, signal }),
|
|
// A DELETE body is optional; the favorites store names its file in one.
|
|
del: (url, { signal, body, keepalive } = {}) => request("DELETE", url, { body, signal, keepalive }),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {{lifetime?: AbortSignal}} [options] - `lifetime` ends with the page (its pagehide):
|
|
* then no call starts a request, the one in flight is aborted, and both resolve to STALE.
|
|
* WebKit fails a fetch that a leaving page starts and logs it as a page error.
|
|
* @returns {(run: (signal: AbortSignal) => Promise<*>) => Promise<*>} wrapper that
|
|
* aborts the previous call's signal and resolves superseded calls to STALE
|
|
*/
|
|
function latestOnly({ lifetime } = {}) {
|
|
let current = null;
|
|
lifetime?.addEventListener("abort", () => current?.abort(), { once: true });
|
|
return async function (run) {
|
|
if (current) current.abort();
|
|
if (lifetime?.aborted) {
|
|
current = null;
|
|
return STALE;
|
|
}
|
|
const controller = new AbortController();
|
|
current = controller;
|
|
try {
|
|
const result = await run(controller.signal);
|
|
return controller === current && !lifetime?.aborted ? result : STALE;
|
|
} catch (error) {
|
|
if (controller !== current || lifetime?.aborted) return STALE;
|
|
throw error;
|
|
}
|
|
};
|
|
}
|
|
|
|
const api = { createApiClient, latestOnly, resolveUrl, fetchUrl, ApiError, STALE };
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
else root.ApiClient = api;
|
|
})(typeof window !== "undefined" ? window : globalThis);
|