diff --git a/tests/js/image-helpers.test.js b/tests/js/image-helpers.test.js new file mode 100644 index 0000000..65878ed --- /dev/null +++ b/tests/js/image-helpers.test.js @@ -0,0 +1,52 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { formatImageCaption, workflowDocumentHtml } = require("../../web/js/image-helpers.js"); + +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(" + diff --git a/web/gallery.html b/web/gallery.html index ea388e6..4185bf9 100644 --- a/web/gallery.html +++ b/web/gallery.html @@ -6,6 +6,7 @@ PromptManager - Gallery + diff --git a/web/js/admin.js b/web/js/admin.js index eb716bd..a9ded6b 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -1434,14 +1434,21 @@ content.classList.remove("hidden"); empty.classList.add("hidden"); - content.innerHTML = images.map((image, index) => ` + const formatFileSize = (bytes) => this.formatFileSize(bytes); + content.innerHTML = images.map((image, index) => { + const generated = new Date(image.generation_time); + const when = escapeHtml(`${generated.toLocaleDateString()} ${generated.toLocaleTimeString()}`); + const fileUrl = `/prompt_manager/images/${escapeHtml(image.id)}/file`; + const caption = ImageHelpers.formatImageCaption(image, { formatFileSize }); + const details = ImageHelpers.formatImageCaption(image, { formatFileSize, separator: ' • ' }); + return `
- Generated image ${index + 1}
@@ -1451,15 +1458,15 @@
- ${new Date(image.generation_time).toLocaleDateString()} ${new Date(image.generation_time).toLocaleTimeString()} + ${when}
- ${image.width && image.height ? `${image.width}×${image.height}` : 'Unknown size'} - ${image.file_size ? ` • ${this.formatFileSize(image.file_size)}` : ''} + ${details}
- `).join(''); + `; + }).join(''); // Store images for navigation this.currentGalleryImages = images; @@ -2856,15 +2863,12 @@ showWorkflowData() { if (this.currentMetadata && this.currentMetadata.workflow) { const newWindow = window.open('', '_blank'); - newWindow.document.write(` - - ComfyUI Workflow Data - -

ComfyUI Workflow JSON

-
${JSON.stringify(this.currentMetadata.workflow, null, 2)}
- - - `); + if (!newWindow) { + this.showNotification('Popup blocked: allow popups to view the workflow', 'warning'); + return; + } + newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow)); + newWindow.document.close(); } } diff --git a/web/js/gallery.js b/web/js/gallery.js index 2a00939..bc926ac 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1461,15 +1461,12 @@ showWorkflowData() { if (this.currentMetadata && this.currentMetadata.workflow) { const newWindow = window.open('', '_blank'); - newWindow.document.write(` - - ComfyUI Workflow Data - -

ComfyUI Workflow JSON

-
${JSON.stringify(this.currentMetadata.workflow, null, 2)}
- - - `); + if (!newWindow) { + this.showNotification('Popup blocked: allow popups to view the workflow', 'warning'); + return; + } + newWindow.document.write(ImageHelpers.workflowDocumentHtml(this.currentMetadata.workflow)); + newWindow.document.close(); } } diff --git a/web/js/image-helpers.js b/web/js/image-helpers.js new file mode 100644 index 0000000..9c29786 --- /dev/null +++ b/web/js/image-helpers.js @@ -0,0 +1,68 @@ +/** + * Pure helpers for image entries rendered by the admin and gallery pages. + * + * Image rows come from the database (`width`, `height`, `file_size` are stored + * from a POST body and may be strings) or from the output-folder scan (`id` is + * a path digest, `path` is the file). Everything these helpers return for HTML + * is already escaped, so callers can drop it into text or a quoted attribute. + * + * Loadable in the browser (window.ImageHelpers) and in Node for tests. + */ +(function (root) { + "use strict"; + + const escapeHtml = + typeof module !== "undefined" && module.exports + ? require("./html-escape.js").escapeHtml + : root.escapeHtml; + + const UNKNOWN_SIZE = "Unknown size"; + + /** + * "640×480 | 1.2 MB" for a card body or a viewer caption, escaped. + * + * @param {{width?: unknown, height?: unknown, file_size?: unknown}|null|undefined} image + * @param {{formatFileSize?: (bytes: number) => string, separator?: string}} [options] + * @returns {string} escaped text; "Unknown size" when a dimension is missing + */ + function formatImageCaption(image, { formatFileSize, separator = " | " } = {}) { + const entry = image || {}; + const parts = []; + if (entry.width && entry.height) { + parts.push(`${escapeHtml(entry.width)}×${escapeHtml(entry.height)}`); + } else { + parts.push(UNKNOWN_SIZE); + } + const bytes = Number(entry.file_size); + if (formatFileSize && Number.isFinite(bytes) && bytes > 0) { + parts.push(escapeHtml(formatFileSize(bytes))); + } + return parts.join(escapeHtml(separator)); + } + + /** + * A complete standalone page showing a workflow as pretty-printed JSON. + * Written into a blank same-origin window, so the JSON must not be able + * to close the
 and inject markup.
+     *
+     * @param {unknown} workflow
+     * @returns {string} HTML document
+     */
+    function workflowDocumentHtml(workflow) {
+        const json = escapeHtml(JSON.stringify(workflow, null, 2));
+        return [
+            "",
+            "",
+            "ComfyUI Workflow Data",
+            "",
+            "

ComfyUI Workflow JSON

", + `
${json}
`, + "", + "", + ].join("\n"); + } + + const api = { formatImageCaption, workflowDocumentHtml }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.ImageHelpers = api; +})(typeof window !== "undefined" ? window : globalThis);