From da322f8c947016f682c8ecf9e1c0719841e6ed75 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 8 Oct 2026 18:53:11 -0700 Subject: [PATCH] Hardening: security fixes, queue-time run counting, background jobs, 85%+ test coverage (#153) * chore: valid packaging metadata, flake8 config file, ignore local config with secrets pyproject.toml carried requirements.txt comment lines as dependencies, which fails PEP 508 parsing. flake8 never read [tool.flake8] from pyproject, so the settings move to .flake8 and pre-commit runs the full rule set. config.json (holds the CivitAI API key and machine paths) and other local artifacts are now ignored. coverage joins the dev requirements. * ci: test on Linux, Windows and macOS with a coverage gate and lint job * refactor: drop validate_workflow_name, the column it validated no longer exists * chore: keep agent worktrees out of coverage measurement * fix: image routes serve only media files inside allowed directories serve_image and serve_output_image now reject anything whose extension is not in IMAGE_EXTENSIONS, fail closed when no output directory is configured, and answer 403 (not 404) for dot-dot, absolute and symlink-escaping paths. Containment is checked after realpath+normcase so it behaves the same on Windows, macOS and Linux. 500 responses use safe_error_message so OSError text no longer leaks absolute paths. * fix: bound page sizes on list endpoints /search, /recent and /images/recent share parse_page_params: limit is clamped to [1, MAX_PAGE_LIMIT=500], offset to >= 0, and non-integer values answer 400 instead of 500. /search now honours offset and all three return a pagination block with the effective limit and offset. * perf: thumbnail generation no longer blocks the event loop generate_thumbnails_with_progress is split into _thumbnail_targets (scan, executor), _generate_one (one PIL/ffmpeg job, executor) and an async driver that awaits each file via _run_in_executor, emits one progress frame per file and yields between files. Per-file failures are reported as file_error events (error stays fatal for the UI) and the loop continues. The wildcard Access-Control-Allow-Origin header is gone. The blocking POST variant reuses the same helpers. The scan never follows symlinks and is capped by MAX_SCAN_DEPTH / MAX_THUMBNAIL_FILES. * fix: cap every request-driven resource in the image routes - /images/recent, /images/all and /images/output parse limit/offset through parse_page_params on every path (absent, 0, negative, float strings), and the clamped values are what reach the DB. /images/all gets its own MAX_BULK_LIMIT ceiling instead of an unbounded query; /images/recent caps its fetch window at MAX_RECENT_IMAGES_WINDOW. - parse_page_params also clamps offset to MAX_PAGE_OFFSET so values past int64 no longer surface as a 500 from SQLite. - Gallery and thumbnail scans share _iter_media_files: no symlink following, MAX_SCAN_DEPTH, and MAX_GALLERY_FILES / MAX_THUMBNAIL_FILES caps. Thumbnail size comes only from named presets. - JSON bodies on /images/link and /images/generate-thumbnails are read in bounded chunks; over MAX_JSON_BODY_BYTES answers 413, malformed or non-object JSON answers 400. - /images/link only accepts media paths inside the allowed directories (403 otherwise), closing a path-existence oracle. * test: cover image and prompt routes Every route in py/api/images.py and py/api/prompts.py now has a success path and an error path test: save/update/delete/rate/tag/category/bulk endpoints, image list/link/unlink/delete/metadata/scan endpoints, the thumbnail SSE stream, clear-thumbnails, and the video thumbnail chain (fake cv2 success/failure, ffmpeg absent or failing, PIL placeholder). Combined statement+branch coverage: images.py 97.7%, prompts.py 97.4%. * fix: autotag endpoints no longer read arbitrary paths or run on GET * fix: CivitAI key is never returned or sent off-host * fix: autotag/single checks real paths and DB rows against output dirs * fix: CivitAI downloads never follow redirects * refactor: autotag model loading is injectable * test: cover autotag and lora integration * fix: saving LoRA settings without a key keeps the stored CivitAI key * refactor: share the internal error message in autotag routes * fix: never treat negative-only PromptManager nodes as the run prompt run_prompt_nodes fell back to every PromptManager node whenever no positive node was traced, including graphs that do have a sampler and whose only PromptManager node feeds its negative input. Fall back only when the graph has no recognisable sampler (new has_sampler helper). Samplers named like one are recognised even without a model link. Adversarial graphs (cycles, 10k-node fan-out, non-dict inputs) are covered. * fix: do not follow negative text inputs when tracing the positive prompt _TEXT_KEY matched input names such as text_negative, so a styler node feeding a sampler's positive input pulled its negative PromptManager node into the positive trace. Input names matching (^|_)neg(ative)?($|_) are now skipped (new follows_input helper). * fix: count prompt runs when ComfyUI dequeues the prompt, not when it is submitted The on-prompt hook runs before ComfyUI validates the request, so counting there bumped run_count for prompts that were rejected or deleted from the queue. The hook now only resolves the positive PromptManager texts, mints a UUID4 prompt_id when the client sent none, and stashes the hashes under that id (QueuedRuns, bounded to 1000 entries). PromptQueue.get is wrapped idempotently; when a prompt is dequeued for execution its stashed runs are counted (or remembered as pending first use). Servers without a prompt_queue fall back to counting at submit time with one warning. __init__ defines _global_db before the monitoring block and passes a factory that raises RuntimeError while the database is unavailable; the package is now imported under test with server and folder_paths stubbed. * fix: keep batch image queue aligned when unrelated workflows save images The metadata strategy returned None for images whose embedded workflow has no positive PromptManager node, so the handler popped the batch FIFO for them and every later batch image linked to the wrong prompt. It now returns a tri-state: linked (prompt found), foreign (no PromptManager prompt in the workflow, so the image is skipped and no fallback applies) or unknown (only then the queue, snapshot, tracker and recent-prompt fallbacks run). * fix: image monitor validates directories before creating the observer start_monitoring assigned self.observer before checking that any directory existed, so with no valid directory the monitor was stuck "already running" and stop_monitoring raised on join of a never-started observer. Directories are validated first and only a started observer is kept; stop_monitoring is safe to call at any time and always joins. New is_monitoring property. The per-image threading.Timer is replaced by one daemon worker: paths are deduplicated and bounded (MAX_PENDING_IMAGES), processed in creation order (batch images pop the prompt queue in order) after the processing delay and once the file size is unchanged across two reads. * refactor: one prompt hash implementation utils.hashing.generate_prompt_hash is now the only prompt hash: PromptManagerBase._generate_hash delegates to it and the inline hashlib.sha256 copies in the prompt tracker and image monitor are gone. Remove PromptManagerBase._open_web_interface (it targeted a web_interface folder that never existed) and the unused restart_gallery.py script. IS_CHANGED on PromptManager and PromptManagerText now includes category and tags, so editing either re-runs the node and updates the saved metadata. * fix: stop ComfyUI from loading the admin bundle into the canvas page WEB_DIRECTORY = "web" made ComfyUI inject every .js under web/ (admin.js, gallery.js, vendored Tailwind and ViewerJS) into the canvas page. The node extension moves to web/comfy/prompt_manager.js (content unchanged; its ../../scripts/app.js import resolves against the served URL, which is the same) and WEB_DIRECTORY points at that folder only. Admin and gallery assets are still served from web/ by the extension's own routes. tests/test_sort_contract.py still reads web/prompt_manager.js and must be updated to the new path by its owner. * fix: image monitor recent-prompt fallback reads the paginated result get_recent_prompts returns {"prompts": [...]} but _get_fallback_prompt indexed it like a list, so the last-resort link to the most recent prompt always failed silently. Accept both shapes. * test: cover prompt_manager_base, prompt_manager and prompt_search_list Node base: init wiring, metadata update on re-save, run counting, database error paths, tag parsing, search and recent API wrappers, gallery start failure, LoRA trigger-word injection (disabled, missing modules, enabled). PromptManager: missing CLIP error, encoding of the combined text with a fake CLIP, tracking failure tolerance, empty text. PromptManagerText: tracking failure tolerance. PromptSearchList: filters, multipart and LoRA-only skipping, newline collapsing, preview, empty and error results. * fix: diagnostics dependency check survives a watchdog without __version__ check_dependencies read watchdog.__version__ for its log line; recent watchdog releases have no such attribute, so the AttributeError escaped and took the whole diagnostics run down. Read it with a default. Also covers utils/diagnostics.py: database and images-table checks against a real temporary database, file system check, ComfyUI output detection (folder_paths, configured directories, relative fallback, nothing found), dependencies, the full run and the test image link. * test: cover utils/logging_config Isolated logger manager on a temporary log directory: memory buffer order, level filter and 1000-entry cap, rotation and backup listing, tail and whole file reads, path traversal rejection, truncation (including removal errors), level changes across prompt_manager loggers, handler rebuilds. * test: cover utils/comfyui_integration Singleton re-initialisation keeps registrations, latest prompt per thread, cross-thread fallback with the five-minute recency limit, global lookup for the current thread, and cleanup of old registrations. * test: cover __init__ Version parsing from pyproject (and the unknown fallback), and a server whose add_on_prompt_handler raises: nodes and monitoring still come up. * docs: point at the moved canvas extension under web/comfy * fix: give every thread its own sqlite connection PromptModel.get_connection() handed every thread the same check_same_thread=False connection, so a commit or rollback from the watchdog, executor or queue-hook thread could land on another thread's half-done write (sqlite3 raised 'bad parameter or other API misuse' under concurrent saves). Connections now live in threading.local, each with WAL, Row factory, foreign_keys and busy_timeout, and are tracked so they can be closed. PromptModel.close() / PromptDatabase.close() close the calling thread's connection so tests and restores can delete the file on Windows. * fix: saving an existing prompt concurrently returns its id Node execution, the queue hook and the image monitor all do get_prompt_by_hash() then save_prompt(). When two of them race on the same text the second INSERT failed on UNIQUE(hash) and surfaced an IntegrityError. save_prompt now catches it, re-fetches by hash and returns the existing id, so eight threads saving one prompt end with a single row and every record_prompt_use counted. * fix: backups include WAL data and restores are integrity-checked The database runs in WAL mode, so a shutil.copy2 of prompts.db silently dropped every committed row still in prompts.db-wal; the backup download and the pre-restore safety copy both did that. Restore also overwrote the live file with connections open, read the whole upload into memory before checking the 100 MB cap and never ran PRAGMA integrity_check. PromptModel.backup_database now uses the SQLite online backup API, verify_database_file opens a candidate read-only and checks header, integrity and the prompts table, and restore_from_file backs up the live database, closes every thread's connection, copies the file, removes stale -wal/-shm and re-runs migrations. The admin handlers stream the upload to a temp file with an early size cutoff, reject anything verify_database_file refuses with a 400 and keep the {success: ...} JSON shape. The schema-init connection is now closed instead of leaking. * fix: migrations are idempotent and schema init runs once per process Every PromptDatabase() (nodes, image monitor, API, diagnostics) re-ran table creation and all migrations. Schema init is now cached per normalised path (class-level set under a lock) and re-run only when the file disappeared, after a restore, or after reset_schema_cache(). The copy-and-swap migrations drop a stale *_new table left by an interrupted run, execute inside an explicit transaction with PRAGMA foreign_keys = OFF (DROP TABLE prompts used to cascade-delete every generated_images row), roll back cleanly and log and re-raise sqlite3.Error instead of swallowing it. * fix: image uniqueness by full path, escaped LIKE search, no usage stamp on save generated_images was UNIQUE(prompt_id, filename) on the basename, so ComfyUI's same-named files in different date folders were silently ignored by INSERT OR IGNORE, and the lastrowid == 0 branch after it never ran. The table now carries file_path (normcase/normpath/abspath of the image path) with UNIQUE(prompt_id, file_path); the migration collapses rows that normalise to one file and supersedes the old basename migration, which would otherwise undo it on every start. search_prompts and the tag search escaped nothing, so 100% matched '100 percent' and a_b matched aXb; user text now goes through escape_like with ESCAPE. save_prompt no longer stamps last_used_at, the usage backfill runs only on the upgrade that adds the columns, and the Recently Used / Most Used orders put never-run prompts (NULL) after run ones instead of first. * fix: escape LIKE wildcards in image prompt search search_images_by_prompt interpolated the user's text into LIKE unescaped, so '50%' also matched '50 percent'. It now goes through the same escape_like helper as search_prompts. Also drops an unused import and an unused cursor assignment flagged by pyflakes. * test: cover database operations Behaviour tests for export (json/csv/errors), duplicate detection and merging, image metadata storage and lookup, missing-file cleanup, statistics, bulk operations, orphan pruning, consistency checks, subfolder listing, tag queries and management edge cases, legacy tag column parsing, config-driven path resolution, model housekeeping and the TEXT -> INTEGER prompt_id migration. database/operations.py and database/models.py are both above 85% statement+branch coverage. * test: isolate the clock and log buffer from background threads of other tests * fix: gallery roots are restricted to ComfyUI directories A gallery root could be set to '/' (or 'C:\') through POST /prompt_manager/settings because the deny list only matched a few system prefixes. The root was persisted to config.json and fed into the image-serving containment check, which then allowed any file on disk to be read through /prompt_manager/images/serve/. - GalleryConfig.validate_gallery_root() accepts a directory only when its realpath lies inside a ComfyUI directory (folder_paths output/input/ temp/user) or a directory listed in PROMPT_MANAGER_EXTRA_GALLERY_ROOTS; filesystem roots, the home directory, files and missing paths are rejected. - save_settings returns 400 and persists nothing when any root fails. - _get_all_output_dirs() filters configured roots through the validator and never falls back to a rejected root. - PROMPT_MANAGER_CONFIG_PATH relocates config.json; load/save default to it. * fix: log config accepts only known keys POST /prompt_manager/logs/config forwarded the whole request body into PromptManagerLogger.update_config(), so any key could be injected into the logging configuration. The handler now accepts only LOG_CONFIG_KEYS (level, max_file_size, backup_count, console_logging, file_logging, buffer_size), type-checks each value (level name, non-negative int, bool) and answers 400 listing unknown keys. Malformed or non-object bodies are 400 instead of 500. * fix: no absolute server paths in API responses Settings, diagnostics, duplicate/output scans and the logging endpoints returned absolute filesystem paths, and several error envelopes echoed str(e) which for OSError carries the full path. - _public_path() renders a path relative to the ComfyUI base directory (folder_paths.base_path, else the output dir's parent) or to the parent of an opted-in extra gallery root, falling back to the basename; _public_error() reduces OSError to strerror + basename. - GET /settings returns gallery roots and monitored directories in that form; POST /settings resolves relative roots against the same anchors (GalleryConfig.resolve_gallery_root) so the GET output round-trips. - Diagnostics output_dirs, monitored_directories and the missing-DB message no longer carry absolute paths; the image monitor is reached through the package-or-top-level import fallback instead of a three-dot relative import that failed outside the package. - Duplicate scan 'path' is now relative to the output root and the delete endpoint accepts relative paths. - /logs/files 'path' and /logs/stats 'log_directory' use the public form; unexpected errors answer with a generic message. * perf: run duplicate and output scans off the event loop The duplicate scan walked the output tree and hashed every file on the aiohttp event loop, and the duplicate delete removed files inline; the output scan ran its per-file database work one executor hop at a time. - _find_duplicate_images_sync / _describe_media_file do the walk and hashing; find_duplicate_images() runs them via _run_in_executor. - _delete_duplicate_images_sync / _delete_one_duplicate do the removal (absolute or output-relative paths) off the loop. - The output scan is split into _collect_output_media_sync, _extract_batch_metadata_sync and _ingest_scan_batch_sync (one executor call per batch for all DB work) with a small SSE generator. - Media collection is shared and de-duplicates by normcase'd path. * refactor: settings reads the image monitor through its module get_settings located the ImageMonitor singleton by scanning sys.modules for any module with 'image_monitor' in its name, a workaround for the package being importable under two identities. It now uses the same try-package / except-top-level import as the rest of the API (_current_image_monitor) and falls back to the configured roots when no monitor is running. * fix: clamp the logs limit and reject non-integers GET /prompt_manager/logs accepted any limit up to 1000 and answered 500 when the value was not an integer. The limit is now clamped to [1, 500] and a non-integer value is a 400. * test: cover core API module and gzip middleware * refactor: split diagnostics, maintenance and HTML serving into helpers - run_diagnostics composes four module-level probes (_diagnose_database, _diagnose_dependencies, _diagnose_output_dirs, _diagnose_image_monitor). - run_maintenance is table-driven: _maintenance_runners maps each operation name to a callable and _run_maintenance_operations reports per-operation success or a public error message. - _read_json answers 400 for malformed or non-object JSON bodies in settings, duplicate delete, test-link and maintenance instead of 500. - test_image_link delegates to _link_test_image. - The four HTML page routes share _serve_html_page. * test: cover admin and core API routes Route tests for stats, cleanup, duplicate scan/delete, settings, diagnostics, test-link, maintenance (every operation, success and failure), the output scan internals, and contract-level tests for backup/restore (status codes and envelope only). * test: make admin, logging and diagnostics tests independent of test order The diagnostics message no longer carries the absolute path, the restore test closes its raw sqlite connections before the live file is replaced, the logs tests declare their own folder_paths base directory, and the isolated logger filters every handler to the test thread. * fix: truncating logs no longer rolls the main log over or pads it with zeros truncate_logs emptied prompt_manager.log through a second open(), leaving the rotating handler's stream at its old offset: the next record was written past the new end (zero padding on POSIX) and tell() still reported the old size, so the file rolled over and resurrected the backup that had just been deleted. It also logged once per file from inside the deletion loop, which could do the same with a small size limit. The main log is now truncated through the handler's own stream and a single summary line is logged afterwards. * fix: HTML escaping covers attribute context escapeHtml now escapes & < > " ' (shared web/js/html-escape.js, loaded by admin, gallery and metadata pages) instead of the textContent/innerHTML round-trip that left quotes intact, so a tag, category, checkpoint or file name containing a quote can no longer break out of a data-*, value or title attribute. Inline onclick handlers that interpolated server data now carry data-action and data-* attributes and are dispatched through one delegated listener per page (web/js/data-actions.js); the img onerror fallback uses data-fallback-src with a capturing error listener. Every remaining template value written into an attribute or text node (prompts, checkpoints, file paths, log lines, directory names, categories, suggestions) is escaped. metadata.html no longer builds an "explorer /select" shell command from the file name (Electron-only branch); the path is copied to the clipboard. * refactor: remove shadowed and dead frontend code admin.js defined parsePNGMetadata, extractComfyUIData, showFullPrompt, showWorkflowData, downloadWorkflowJSON and copyAllMetadata twice; the first definitions were unreachable because the later ones shadow them. updateMetadataPanel, showMetadataError, copyPrompt and tryFallbackMetadata only ever wrote to #metadataContent, an element no page creates, so they are gone too. gallery.js defined escapeHtml and showNotification twice. The admin keydown/resize handlers referenced closeImageViewer, previousImage, nextImage and applyImageSizing, none of which exist, and the imageViewMode state they used; only the Escape-closes-gallery branch remains. Debug console.log calls are removed; the ones that were the only statement of a catch block are console.warn so parse failures stay visible. * fix: stale responses, double submits and leaked listeners web/js/api-client.js wraps fetch with one failure path (ApiError for non-2xx, success:false and non-JSON bodies) and latestOnly(), which aborts the previous in-flight request and resolves superseded calls to STALE. The admin search and recent-page loads share one guard, the gallery page load has its own, so fast typing or clicking can no longer paint an older result over a newer one. In-place prompt editing used a blur listener with {once:true} plus a keydown listener added on every edit, which sent the PUT twice and stacked handlers. The list now has one keydown/focusout pair bound at construction and finishEdit() clears the edit state before touching the element, so a save is sent exactly once. Mutations re-fetch the page currently shown (refreshList) instead of calling search(), and delete, bulk delete, edit, restore and scan no longer reload the whole page. Pagination state is committed only after a successful fetch via the pure ListState.nextPageState(current, response) in both admin and gallery, so a failed or aborted page request leaves the current page intact. The gallery lightbox removed its keydown handlers on a non-existent 'remove' event; a MutationObserver now drops them when the modal leaves the document. web/js/sse-stream.js reads server-sent events over fetch so POST-only progress endpoints can stream. * fix: follow the POST-only autotag streams and the new LoRA and list contracts autotag/download and autotag/start are POST-only now; EventSource can only GET, so both pages stream their progress through SseStream.connect(url, {method: 'POST'}), which keeps the onmessage/onerror/close surface the handlers already use. autotag/single is called with image_id and the re-tag dialog shows the server's filename instead of splitting an absolute path. lora/status reports has_civitai_api_key instead of the key: the field shows a 'key is stored' placeholder, an empty field keeps the key on save and a 'Clear the stored key on save' checkbox sends clear_civitai_api_key. Thumbnail generation reports per-file failures as file_error events; both progress views count them (and show error_count from progress) without closing the stream. The auto-tag review follows pagination.has_more on the capped /images/all endpoint, and /search is paged like /recent so paging in search mode no longer switches the list back to recent prompts. * refactor: one PNG metadata parser admin.js, gallery.js and metadata.html each carried a copy of the PNG chunk walker that reads ComfyUI's tEXt/iTXt entries (admin.js had two until the shadowed one was removed). web/js/png-metadata.js is now the only parser: it checks the signature, bounds-checks every chunk so a truncated file or a bogus length cannot throw, decodes tEXt as Latin-1 and iTXt as UTF-8, skips compressed zTXt/iTXt payloads instead of decoding them as garbage, and stops at IEND. The three page-level parsePNGMetadata functions delegate to it. * fix: tags deep link renders and admin modals close admin.html loaded tags-page.js after admin.js, so opening the page at #/tags found no TagsPageManager and rendered nothing; the script order is now theme-bridge, html-escape, api-client, png-metadata, (list-state, sse-stream, data-actions), viewer, comfy-metadata, prompt-list-sort, view-router, tags-page, admin. Route resolution lives in the pure ViewRouter.resolveView(). The auto-tag modal's close buttons called a bare hideModal(), which only gallery.js defines; they call window.admin.hideModal(). PromptAdmin is constructed from startAdmin() on DOMContentLoaded (or immediately when the document is already parsed) instead of at script evaluation time. * test: cover frontend modules browser-globals.test.js loads every page module through vm with a fake window, the way the pages do, and asserts the global each one exposes (HtmlEscape/escapeHtml, DataActions, ApiClient, ListState, SseStream, PngMetadata, ViewRouter, PromptListSort, ComfyMetadata). sse-stream gains cases for a POST body, missing handlers and close() before or during delivery. Every pure module is above 85% line coverage under node --test --experimental-test-coverage. * fix: page image listings in SQL and drop three unused helpers get_recent_images and search_images_by_prompt take limit/offset so the recent and search image routes no longer over-fetch a 5000-row window and slice it. generate_content_hash, PromptModel.migrate_database and find_duplicates had no callers outside their own tests. The isolated logger tests re-apply their thread filter after update_config rebuilds the handlers. Long lines in the touched database and image modules are wrapped so the full flake8 hook passes. * fix: restore through SQLite's backup API, close_all for shared files, safer output scans restore_from_file copied the uploaded file over the live database and deleted its WAL while other PromptModel instances (nodes, image monitor, queue hook) still held connections, which corrupts on POSIX and fails on Windows. It now copies pages through the online backup API into the live connection so every open handle sees the new content. PromptDatabase.close_all() closes the connections of every thread, which tests need before deleting a database. Output scans skip symlinked files, filesystem entries get a stable digest id instead of a per-process hash(), and thumbnail streaming stops when the client disconnects. * style: wrap long lines in node modules Split tooltip, description and docstring strings that exceed 88 columns using implicit concatenation; string values are unchanged. Drop the unused typing imports from prompt_search_list and mark the deliberately late node imports in __init__ with noqa: E402. * style: wrap long lines in the API layer, autotag and config Split long f-string log messages, response messages, URL builders and docstrings; DEFAULT_PROMPT and SYSTEM_PROMPT are rewritten as implicit concatenations with identical values. Drop the unused shutil import in admin and the unused HFCacheInfo import in autotag; keep the hf_hub_download/snapshot_download availability probe with noqa: F401 and mark the deliberately late imports in config with noqa: E402. * style: wrap long lines and drop unused imports in utils Re-wrap module docstrings, comments and f-string log messages; remove the unused typing.List and PngInfo imports; mark the PIL availability probe in diagnostics with noqa: F401; turn two placeholder-less f-strings into plain strings; strip trailing whitespace inside the diagnostics SQL. * style: wrap long lines in tests Split long docstrings and SQL literals (values unchanged) and drop the unused os import from test_path_security. * chore: flake8 ignores the gitignored standalone tagger script * fix: escape image dimensions and workflow JSON before rendering openGalleryModal interpolated width, height and file_size (stored from a POST body, so possibly strings) straight into data-caption and the card text. showWorkflowData wrote JSON.stringify(workflow) raw into a blank same-origin window, so a workflow containing "), "<script>x</script>"); +}); + +test("unicode and ordinary text pass through unchanged", () => { + assert.equal(escapeHtml("héllo wörld 日本語 🙂"), "héllo wörld 日本語 🙂"); + assert.equal(escapeHtml("plain text, no specials"), "plain text, no specials"); +}); + +test("already escaped text is escaped again, never double-decoded", () => { + assert.equal(escapeHtml("&"), "&amp;"); +}); + +test("null and undefined become the empty string", () => { + assert.equal(escapeHtml(null), ""); + assert.equal(escapeHtml(undefined), ""); +}); + +test("numbers and booleans are coerced to strings", () => { + assert.equal(escapeHtml(42), "42"); + assert.equal(escapeHtml(0), "0"); + assert.equal(escapeHtml(1.5), "1.5"); + assert.equal(escapeHtml(false), "false"); +}); + +test("objects are coerced through String()", () => { + assert.equal(escapeHtml({ toString: () => "" }), "<b>"); +}); + +test("escapeAttr is the same function as escapeHtml", () => { + assert.equal(escapeAttr, escapeHtml); + assert.equal(escapeAttr(`a"b`), "a"b"); +}); diff --git a/tests/js/image-helpers.test.js b/tests/js/image-helpers.test.js new file mode 100644 index 0000000..0b96278 --- /dev/null +++ b/tests/js/image-helpers.test.js @@ -0,0 +1,110 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + formatImageCaption, + workflowDocumentHtml, + autotagSingleBody, + fallbackImageSource, +} = require("../../web/js/image-helpers.js"); + +const WD14 = { modelType: "wd14-vit", generalThreshold: 0.35, characterThreshold: 0.85, prompt: "ignored" }; +const CAPTION = { modelType: "blip", generalThreshold: 0.35, characterThreshold: 0.85, prompt: "describe it" }; + +test("autotagSingleBody sends image_id for a database row", () => { + const body = autotagSingleBody({ id: 42, prompt_id: 7, image_path: "/out/a.png" }, WD14); + assert.equal(body.image_id, 42); + assert.equal("path" in body, false); +}); + +test("autotagSingleBody sends path for an output-folder entry whose id is a digest", () => { + const body = autotagSingleBody({ id: "3f2a9c1b0d4e5f67", path: "/out/sub/a.png", filename: "a.png" }, WD14); + assert.equal(body.path, "/out/sub/a.png"); + assert.equal("image_id" in body, false); +}); + +test("autotagSingleBody prefers path when a numeric-looking string id has no prompt link", () => { + const body = autotagSingleBody({ id: "1234567890abcdef", path: "/out/b.png" }, WD14); + assert.deepEqual(Object.keys(body).filter((k) => k === "path" || k === "image_id"), ["path"]); +}); + +test("autotagSingleBody adds wd14 thresholds and no prompt", () => { + const body = autotagSingleBody({ id: 1 }, WD14); + assert.equal(body.model_type, "wd14-vit"); + assert.equal(body.general_threshold, 0.35); + assert.equal(body.character_threshold, 0.85); + assert.equal("prompt" in body, false); +}); + +test("autotagSingleBody adds the prompt for caption models and no thresholds", () => { + const body = autotagSingleBody({ id: 1 }, CAPTION); + assert.equal(body.model_type, "blip"); + assert.equal(body.prompt, "describe it"); + assert.equal("general_threshold" in body, false); +}); + +test("autotagSingleBody does not mutate its inputs", () => { + const image = Object.freeze({ id: 1 }); + const settings = Object.freeze({ ...WD14 }); + assert.doesNotThrow(() => autotagSingleBody(image, settings)); +}); + +const XSS = '">'; +const formatFileSize = (bytes) => `${bytes} B`; + +test("formatImageCaption joins dimensions and size with the separator", () => { + const caption = formatImageCaption({ width: 640, height: 480, file_size: 2048 }, { formatFileSize }); + assert.equal(caption, "640×480 | 2048 B"); +}); + +test("formatImageCaption honours a custom separator and omits a missing size", () => { + assert.equal(formatImageCaption({ width: 1, height: 2 }, { formatFileSize, separator: " • " }), "1×2"); +}); + +test("formatImageCaption falls back to Unknown size when a dimension is missing", () => { + assert.equal(formatImageCaption({ width: 640 }, { formatFileSize }), "Unknown size"); + assert.equal(formatImageCaption({}, { formatFileSize }), "Unknown size"); + assert.equal(formatImageCaption(null, { formatFileSize }), "Unknown size"); +}); + +test("formatImageCaption escapes string dimensions so they cannot break out of an attribute", () => { + const caption = formatImageCaption({ width: XSS, height: 10 }, { formatFileSize }); + assert.ok(!caption.includes("<"), caption); + assert.ok(!caption.includes('"'), caption); + assert.ok(caption.includes(""><img"), caption); +}); + +test("formatImageCaption ignores a non-numeric file_size instead of formatting it", () => { + const caption = formatImageCaption({ width: 1, height: 1, file_size: XSS }, { formatFileSize }); + assert.equal(caption, "1×1"); +}); + +test("formatImageCaption escapes whatever formatFileSize returns", () => { + const caption = formatImageCaption({ width: 1, height: 1, file_size: 5 }, { formatFileSize: () => "" }); + assert.equal(caption, "1×1 | <b>"); +}); + +test("workflowDocumentHtml escapes the JSON so it cannot close the pre block", () => { + const html = workflowDocumentHtml({ note: "" }); + assert.ok(html.includes("</pre><script>"), html); + assert.ok(!html.includes(" + + + + + + + + + @@ -98,10 +107,13 @@ -
+
- + +
@@ -215,6 +227,11 @@ per page
+ +
Loading... @@ -401,6 +418,29 @@

Choose how the Web UI opens from ComfyUI nodes.

+ +
+
+ +

Load the next page of prompts as you scroll. The checkbox in the list header overrides this for the current session.

+
+ +
+ + + +
+

Performance

+ +
+
+ + 1 / 1 cores +
+ +

Threads used by the image rescan and thumbnail generation. More threads finish sooner; fewer leave CPU for ComfyUI while it generates.

+
@@ -442,6 +482,11 @@

Get your key from civitai.com/user/account

+ + +