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.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { readAction, dispatch } = require("../../web/js/data-actions.js");
|
||||
|
||||
test("readAction parses promptId and index as integers", () => {
|
||||
const parsed = readAction({ action: "open-film", promptId: "42", index: "3" });
|
||||
assert.equal(parsed.action, "open-film");
|
||||
assert.equal(parsed.promptId, 42);
|
||||
assert.equal(parsed.index, 3);
|
||||
});
|
||||
|
||||
test("readAction turns missing or non-numeric ids into null", () => {
|
||||
assert.equal(readAction({ action: "edit" }).promptId, null);
|
||||
assert.equal(readAction({ action: "edit", promptId: "abc" }).promptId, null);
|
||||
assert.equal(readAction({ action: "edit", index: "" }).index, null);
|
||||
});
|
||||
|
||||
test("readAction passes string fields through untouched", () => {
|
||||
const parsed = readAction({ action: "remove-tag", tag: `a "quoted" tag`, url: "/x?y=1&z=2", copyText: "p" });
|
||||
assert.equal(parsed.tag, `a "quoted" tag`);
|
||||
assert.equal(parsed.url, "/x?y=1&z=2");
|
||||
assert.equal(parsed.copyText, "p");
|
||||
});
|
||||
|
||||
test("readAction tolerates a missing dataset", () => {
|
||||
assert.equal(readAction(undefined).action, "");
|
||||
assert.equal(readAction(null).promptId, null);
|
||||
});
|
||||
|
||||
test("dispatch calls the matching handler with the parsed action and extras", () => {
|
||||
const calls = [];
|
||||
const handled = dispatch(
|
||||
{ action: "edit", promptId: "7" },
|
||||
{ edit: (a, el) => calls.push([a.promptId, el]) },
|
||||
"element",
|
||||
);
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, [[7, "element"]]);
|
||||
});
|
||||
|
||||
test("dispatch returns false and calls nothing for an unknown or empty action", () => {
|
||||
let called = 0;
|
||||
const handlers = { edit: () => called++ };
|
||||
assert.equal(dispatch({ action: "other" }, handlers), false);
|
||||
assert.equal(dispatch({}, handlers), false);
|
||||
assert.equal(dispatch({ action: "constructor" }, handlers), false, "no prototype lookups");
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run with: node --test tests/js/
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { escapeHtml, escapeAttr } = require("../../web/js/html-escape.js");
|
||||
|
||||
test("escapes the five HTML-significant characters", () => {
|
||||
assert.equal(escapeHtml(`&<>"'`), "&<>"'");
|
||||
});
|
||||
|
||||
test("a double quote cannot break out of an attribute", () => {
|
||||
const payload = `" onmouseover="alert(1)`;
|
||||
const escaped = escapeHtml(payload);
|
||||
assert.ok(!escaped.includes(`"`), escaped);
|
||||
assert.equal(escaped, `" onmouseover="alert(1)`);
|
||||
assert.equal(`<a data-tag="${escaped}">`, `<a data-tag="" onmouseover="alert(1)">`);
|
||||
});
|
||||
|
||||
test("a single quote cannot break out of a single-quoted attribute", () => {
|
||||
const escaped = escapeHtml(`' onclick='alert(1)`);
|
||||
assert.ok(!escaped.includes(`'`), escaped);
|
||||
assert.equal(escaped, `' onclick='alert(1)`);
|
||||
});
|
||||
|
||||
test("a script tag is neutralised in text context", () => {
|
||||
assert.equal(escapeHtml("<script>x</script>"), "<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>" }), "<b>");
|
||||
});
|
||||
|
||||
test("escapeAttr is the same function as escapeHtml", () => {
|
||||
assert.equal(escapeAttr, escapeHtml);
|
||||
assert.equal(escapeAttr(`a"b`), "a"b");
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PromptManager - Admin Dashboard</title>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/data-actions.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PromptManager - Gallery</title>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/data-actions.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
<link rel="stylesheet" href="lib/viewerjs/viewer.min.css">
|
||||
|
||||
+92
-72
@@ -23,6 +23,23 @@
|
||||
|
||||
this.tagsPage = null;
|
||||
|
||||
// Elements rendered with data-action="..." (prompt cards, film strips, log files,
|
||||
// review chips) are dispatched here instead of through inline onclick handlers,
|
||||
// so user-provided strings never land inside JavaScript source.
|
||||
this.actionHandlers = {
|
||||
"copy-prompt": ({ promptId }) => this.copyPromptToClipboard(promptId),
|
||||
"add-tags": ({ promptId }) => this.addTag(promptId),
|
||||
"remove-tag": ({ promptId, tag }) => this.removeTag(promptId, tag),
|
||||
"toggle-tags": ({ promptId }) => this.toggleMainTags(promptId),
|
||||
gallery: ({ promptId }) => this.viewGallery(promptId),
|
||||
edit: ({ promptId }) => this.editPrompt(promptId),
|
||||
delete: ({ promptId }) => this.deletePrompt(promptId),
|
||||
"open-film": ({ promptId, index }) => this.openFilmStripViewer(promptId, index),
|
||||
"download-log": ({ filename }) => this.downloadLogFile(filename),
|
||||
"copy-text": ({ copyText }) => this.copyToClipboard(copyText),
|
||||
"remove-review-tag": ({ index }) => this.removeReviewTag(index),
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -110,6 +127,11 @@
|
||||
// Modals
|
||||
this.bindModalEvents();
|
||||
|
||||
// One delegated click handler for every data-action element; images declare a
|
||||
// data-fallback-src instead of an inline onerror handler
|
||||
document.addEventListener("click", (e) => this.handleActionClick(e));
|
||||
document.addEventListener("error", (e) => this.applyImageFallback(e.target), true);
|
||||
|
||||
// Auto-search on filter changes
|
||||
["searchCategory", "searchFolder"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => this.search());
|
||||
@@ -118,7 +140,7 @@
|
||||
// Sort dropdown: options come from PromptListSort; sorting runs on the server
|
||||
const sortSelect = document.getElementById("sortBy");
|
||||
sortSelect.innerHTML = PromptListSort.SORT_OPTIONS
|
||||
.map((o) => `<option value="${o.value}">${this.escapeHtml(o.label)}</option>`)
|
||||
.map((o) => `<option value="${o.value}">${escapeHtml(o.label)}</option>`)
|
||||
.join("");
|
||||
sortSelect.value = PromptListSort.DEFAULT_SORT;
|
||||
sortSelect.addEventListener("change", () => this.reloadPrompts());
|
||||
@@ -462,6 +484,19 @@
|
||||
return this.listMode === "search" ? this.search() : this.loadRecentPrompts(1);
|
||||
}
|
||||
|
||||
handleActionClick(e) {
|
||||
const target = e.target.closest("[data-action]");
|
||||
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
|
||||
}
|
||||
|
||||
/** Swap a thumbnail for its full-size image once, when the thumbnail fails to load. */
|
||||
applyImageFallback(img) {
|
||||
if (!img || img.tagName !== "IMG" || !img.dataset.fallbackSrc) return;
|
||||
const fallback = img.dataset.fallbackSrc;
|
||||
delete img.dataset.fallbackSrc;
|
||||
img.src = fallback;
|
||||
}
|
||||
|
||||
renderPrompts() {
|
||||
const container = document.getElementById("resultsList");
|
||||
const count = document.getElementById("resultsCount");
|
||||
@@ -491,15 +526,6 @@
|
||||
this.addStarHoverBehavior(prompt.id);
|
||||
});
|
||||
|
||||
// Delegated click handler for remove-tag buttons (avoids inline onclick XSS)
|
||||
container.querySelectorAll('.remove-tag-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const promptId = parseInt(e.target.dataset.promptId);
|
||||
const tag = e.target.dataset.tag;
|
||||
window.admin.removeTag(promptId, tag);
|
||||
});
|
||||
});
|
||||
|
||||
// Load film strips for each prompt (async, non-blocking)
|
||||
this.loadAllFilmStrips(this.prompts);
|
||||
}
|
||||
@@ -550,8 +576,8 @@
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="bg-pm-surface rounded-pm-sm p-4 mb-4 relative group">
|
||||
<div class="prompt-text text-pm leading-relaxed whitespace-pre-wrap" data-id="${prompt.id}">${this.escapeHtml(prompt.text)}</div>
|
||||
<button class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-pm-surface hover:bg-pm-hover text-pm-secondary hover:text-pm p-2 rounded-pm-sm text-sm" onclick="window.admin.copyPromptToClipboard(${prompt.id})">
|
||||
<div class="prompt-text text-pm leading-relaxed whitespace-pre-wrap" data-id="${prompt.id}">${escapeHtml(prompt.text)}</div>
|
||||
<button class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-pm-surface hover:bg-pm-hover text-pm-secondary hover:text-pm p-2 rounded-pm-sm text-sm" data-action="copy-prompt" data-prompt-id="${prompt.id}">
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
@@ -559,15 +585,15 @@
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-pm-secondary mb-3">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>📁</span>
|
||||
<span class="category-text" data-id="${prompt.id}">${this.escapeHtml(category)}</span>
|
||||
<span class="category-text" data-id="${prompt.id}">${escapeHtml(category)}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<span>📅</span>
|
||||
<span>${created}</span>
|
||||
</div>
|
||||
${runLabel ? `<div class="flex items-center space-x-1" title="${this.escapeHtml(lastUsedTitle)}">
|
||||
${runLabel ? `<div class="flex items-center space-x-1" title="${escapeHtml(lastUsedTitle)}">
|
||||
<span>🔁</span>
|
||||
<span>${this.escapeHtml(runLabel)}</span>
|
||||
<span>${escapeHtml(runLabel)}</span>
|
||||
</div>` : ""}
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="rating flex space-x-1" data-id="${prompt.id}" data-rating="${rating}">
|
||||
@@ -580,11 +606,11 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
${tags.slice(0, 10).map(tag => `
|
||||
<span class="inline-flex items-center space-x-1 bg-pm-input text-pm px-3 py-1 rounded-full text-sm border border-pm">
|
||||
<span>${this.escapeHtml(tag)}</span>
|
||||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-prompt-id="${prompt.id}" data-tag="${this.escapeHtml(tag)}">×</button>
|
||||
<span>${escapeHtml(tag)}</span>
|
||||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-action="remove-tag" data-prompt-id="${prompt.id}" data-tag="${escapeHtml(tag)}">×</button>
|
||||
</span>
|
||||
`).join("")}
|
||||
<button class="inline-flex items-center space-x-1 bg-pm-input hover:bg-pm-hover text-pm-secondary px-3 py-1 rounded-full text-sm transition-colors" onclick="window.admin.addTag(${prompt.id})">
|
||||
<button class="inline-flex items-center space-x-1 bg-pm-input hover:bg-pm-hover text-pm-secondary px-3 py-1 rounded-full text-sm transition-colors" data-action="add-tags" data-prompt-id="${prompt.id}">
|
||||
<span>+</span>
|
||||
<span>Add Tags</span>
|
||||
</button>
|
||||
@@ -594,13 +620,13 @@
|
||||
<div class="flex flex-wrap items-center gap-2 max-h-[180px] overflow-y-auto p-2 bg-pm-surface rounded-pm-sm">
|
||||
${tags.slice(10).map(tag => `
|
||||
<span class="inline-flex items-center space-x-1 bg-pm-input text-pm px-3 py-1 rounded-full text-sm border border-pm">
|
||||
<span>${this.escapeHtml(tag)}</span>
|
||||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-prompt-id="${prompt.id}" data-tag="${this.escapeHtml(tag)}">×</button>
|
||||
<span>${escapeHtml(tag)}</span>
|
||||
<button class="remove-tag-btn text-pm-secondary hover:text-pm ml-1" data-action="remove-tag" data-prompt-id="${prompt.id}" data-tag="${escapeHtml(tag)}">×</button>
|
||||
</span>
|
||||
`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
<button class="tags-toggle-btn mt-2 text-sm text-pm-accent hover:text-pm-accent transition-colors flex items-center gap-1" onclick="window.admin.toggleMainTags(${prompt.id})">
|
||||
<button class="tags-toggle-btn mt-2 text-sm text-pm-accent hover:text-pm-accent transition-colors flex items-center gap-1" data-action="toggle-tags" data-prompt-id="${prompt.id}">
|
||||
<span class="toggle-icon">▼</span>
|
||||
<span class="toggle-text">Show ${tags.length - 10} more tags</span>
|
||||
</button>
|
||||
@@ -614,13 +640,13 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-2">
|
||||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" onclick="window.admin.viewGallery(${prompt.id})">
|
||||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="gallery" data-prompt-id="${prompt.id}">
|
||||
🖼️ Gallery
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" onclick="window.admin.editPrompt(${prompt.id})">
|
||||
<button class="px-4 py-2 bg-pm-accent hover:bg-pm-accent-hover text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="edit" data-prompt-id="${prompt.id}">
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-pm-error hover:bg-pm-error text-pm text-sm font-medium rounded-pm-sm transition-colors" onclick="window.admin.deletePrompt(${prompt.id})">
|
||||
<button class="px-4 py-2 bg-pm-error hover:bg-pm-error text-pm text-sm font-medium rounded-pm-sm transition-colors" data-action="delete" data-prompt-id="${prompt.id}">
|
||||
🗑️ Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -676,12 +702,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showNotification(message, type = "info") {
|
||||
const notification = document.createElement("div");
|
||||
|
||||
@@ -765,7 +785,7 @@
|
||||
const data = await response.json();
|
||||
const dirs = data.settings?.monitored_directories || [];
|
||||
if (dirs.length > 0) {
|
||||
statusEl.innerHTML = dirs.map(d => `<div class="truncate" title="${d}">✓ ${d}</div>`).join('');
|
||||
statusEl.innerHTML = dirs.map(d => `<div class="truncate" title="${escapeHtml(d)}">✓ ${escapeHtml(d)}</div>`).join('');
|
||||
} else {
|
||||
statusEl.textContent = 'No directories being monitored (auto-detect on restart)';
|
||||
}
|
||||
@@ -1723,7 +1743,7 @@
|
||||
|
||||
html += `
|
||||
<div class="flex justify-between items-center p-2 bg-pm-input rounded">
|
||||
<span class="text-pm-secondary capitalize">${this.escapeHtml(category)}</span>
|
||||
<span class="text-pm-secondary capitalize">${escapeHtml(category)}</span>
|
||||
<span class="${statusColor} font-mono text-sm">${status}</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -1737,7 +1757,7 @@
|
||||
(result.status === 'warning' ? 'bg-pm-warning/20 border-pm-warning' : 'bg-pm-error-tint border-pm-error');
|
||||
|
||||
html += `<div class="${bgColor} border rounded-pm-sm p-4">`;
|
||||
html += `<h4 class="text-pm font-medium mb-2 capitalize">${this.escapeHtml(category)}</h4>`;
|
||||
html += `<h4 class="text-pm font-medium mb-2 capitalize">${escapeHtml(category)}</h4>`;
|
||||
|
||||
if (result.message) {
|
||||
html += `<p class="text-pm-secondary mb-2">${result.message}</p>`;
|
||||
@@ -1780,7 +1800,7 @@
|
||||
html += `<div class="text-sm text-pm-secondary">`;
|
||||
for (const [dep, available] of Object.entries(result.dependencies)) {
|
||||
const status = available ? '✅' : '❌';
|
||||
html += `<p>${status} ${dep}</p>`;
|
||||
html += `<p>${status} ${escapeHtml(dep)}</p>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
@@ -2209,13 +2229,13 @@
|
||||
container.innerHTML = data.files.map(file => `
|
||||
<div class="bg-pm-surface rounded-pm-sm p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-pm">${file.filename}</span>
|
||||
<span class="text-sm font-medium text-pm">${escapeHtml(file.filename)}</span>
|
||||
${file.is_main ? '<span class="bg-pm-accent text-xs px-2 py-1 rounded">Active</span>' : ''}
|
||||
</div>
|
||||
<div class="text-xs text-pm-secondary mb-2">
|
||||
Size: ${this.formatBytes(file.size)} | Modified: ${new Date(file.modified).toLocaleString()}
|
||||
</div>
|
||||
<button onclick="window.admin.downloadLogFile('${file.filename}')"
|
||||
<button data-action="download-log" data-filename="${escapeHtml(file.filename)}"
|
||||
class="w-full px-3 py-1 bg-pm-success hover:bg-pm-success text-pm text-xs rounded transition-colors">
|
||||
📥 Download
|
||||
</button>
|
||||
@@ -2274,13 +2294,13 @@
|
||||
return `
|
||||
<div class="border-l-2 border-pm pl-3 py-1 hover:bg-pm-surface transition-colors">
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
<span class="text-pm-muted text-xs font-mono w-24 flex-shrink-0">${timestamp.split(' ')[1]}</span>
|
||||
<span class="${levelColor} font-semibold w-16 flex-shrink-0">${log.level}</span>
|
||||
<span class="text-pm-accent text-xs w-32 flex-shrink-0">${log.logger}</span>
|
||||
<span class="text-pm-secondary flex-1">${log.message}</span>
|
||||
<span class="text-pm-muted text-xs font-mono w-24 flex-shrink-0">${escapeHtml(timestamp.split(' ')[1])}</span>
|
||||
<span class="${levelColor} font-semibold w-16 flex-shrink-0">${escapeHtml(log.level)}</span>
|
||||
<span class="text-pm-accent text-xs w-32 flex-shrink-0">${escapeHtml(log.logger)}</span>
|
||||
<span class="text-pm-secondary flex-1">${escapeHtml(log.message)}</span>
|
||||
</div>
|
||||
<div class="text-xs text-pm-muted ml-44">
|
||||
${log.filename}:${log.lineno}
|
||||
${escapeHtml(log.filename)}:${escapeHtml(log.lineno)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -2600,8 +2620,8 @@
|
||||
<!-- File Path -->
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">File Path</h2>
|
||||
<div class="text-sm text-pm-accent hover:text-pm-accent cursor-pointer bg-pm-surface p-2 rounded break-all" onclick="window.admin.copyToClipboard('${filePath}')">
|
||||
${filePath}
|
||||
<div class="text-sm text-pm-accent hover:text-pm-accent cursor-pointer bg-pm-surface p-2 rounded break-all" data-action="copy-text" data-copy-text="${escapeHtml(filePath)}">
|
||||
${escapeHtml(filePath)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2610,7 +2630,7 @@
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">Resources used</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-pm-accent hover:text-pm-accent cursor-pointer">${checkpoint}</div>
|
||||
<div class="text-pm-accent hover:text-pm-accent cursor-pointer">${escapeHtml(checkpoint)}</div>
|
||||
<div class="text-xs text-pm-muted">ComfyUI Generated</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CHECKPOINT</span>
|
||||
@@ -2629,7 +2649,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||||
${positivePrompt.substring(0, 200)}${positivePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(positivePrompt.substring(0, 200))}${positivePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${positivePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" onclick="window.admin.showFullPrompt(\'positive\')">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -2645,7 +2665,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||||
${negativePrompt.substring(0, 200)}${negativePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(negativePrompt.substring(0, 200))}${negativePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${negativePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" onclick="window.admin.showFullPrompt(\'negative\')">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -2654,12 +2674,12 @@
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-3">Other metadata</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CFG SCALE: ${cfgScale}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">STEPS: ${steps}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SAMPLER: ${sampler}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CFG SCALE: ${escapeHtml(cfgScale)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">STEPS: ${escapeHtml(steps)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SAMPLER: ${escapeHtml(sampler)}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SEED: ${seed}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SEED: ${escapeHtml(seed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2783,7 +2803,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
if (!this.currentMetadata) return;
|
||||
|
||||
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||||
const safeType = this.escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const newWindow = window.open('', '_blank');
|
||||
const doc = newWindow.document;
|
||||
doc.open();
|
||||
@@ -3058,7 +3078,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">File Path</h2>
|
||||
<div class="text-sm text-pm-accent hover:text-pm-accent cursor-pointer bg-pm-surface p-2 rounded break-all" data-copy-path="">
|
||||
${metadata.imagePath}
|
||||
${escapeHtml(metadata.imagePath)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3067,7 +3087,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">Resources used</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-pm-accent hover:text-pm-accent cursor-pointer">${metadata.checkpoint}</div>
|
||||
<div class="text-pm-accent hover:text-pm-accent cursor-pointer">${escapeHtml(metadata.checkpoint)}</div>
|
||||
<div class="text-xs text-pm-muted">ComfyUI Generated</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CHECKPOINT</span>
|
||||
@@ -3086,7 +3106,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||||
${metadata.positivePrompt.substring(0, 200)}${metadata.positivePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(metadata.positivePrompt.substring(0, 200))}${metadata.positivePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${metadata.positivePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" data-show-type="positive">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -3102,7 +3122,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded max-h-32 overflow-y-auto">
|
||||
${metadata.negativePrompt.substring(0, 200)}${metadata.negativePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(metadata.negativePrompt.substring(0, 200))}${metadata.negativePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${metadata.negativePrompt.length > 200 ? '<button class="text-pm-accent hover:text-pm-accent text-sm mt-1" data-show-type="negative">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -3111,12 +3131,12 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-3">Other metadata</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CFG SCALE: ${metadata.cfgScale}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">STEPS: ${metadata.steps}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SAMPLER: ${metadata.sampler}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">CFG SCALE: ${escapeHtml(metadata.cfgScale)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">STEPS: ${escapeHtml(metadata.steps)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SAMPLER: ${escapeHtml(metadata.sampler)}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SEED: ${metadata.seed}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded">SEED: ${escapeHtml(metadata.seed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3198,7 +3218,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
showFullPrompt(type) {
|
||||
if (this.currentMetadata) {
|
||||
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||||
const safeType = this.escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const newWindow = window.open('', '_blank');
|
||||
const doc = newWindow.document;
|
||||
doc.open();
|
||||
@@ -3370,7 +3390,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
this.addPromptTags.forEach(tag => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip inline-flex items-center px-2 py-1 bg-pm-accent text-pm text-xs rounded cursor-pointer hover:bg-pm-accent-hover';
|
||||
chip.innerHTML = `${tag} <span class="ml-1">×</span>`;
|
||||
chip.innerHTML = `${escapeHtml(tag)} <span class="ml-1">×</span>`;
|
||||
chip.addEventListener('click', () => {
|
||||
this.addPromptTags = this.addPromptTags.filter(t => t !== tag);
|
||||
this.renderAddPromptTags();
|
||||
@@ -3397,8 +3417,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
suggestionsContainer.innerHTML = matchingTags.map(tag => `
|
||||
<div class="px-3 py-2 hover:bg-pm-hover cursor-pointer text-sm text-pm" data-tag="${tag}">
|
||||
${tag}
|
||||
<div class="px-3 py-2 hover:bg-pm-hover cursor-pointer text-sm text-pm" data-tag="${escapeHtml(tag)}">
|
||||
${escapeHtml(tag)}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
@@ -3419,7 +3439,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
populateAddPromptCategories() {
|
||||
const datalist = document.getElementById('addPromptCategoryList');
|
||||
datalist.innerHTML = this.categories.map(cat => `<option value="${cat}">`).join('');
|
||||
datalist.innerHTML = this.categories.map(cat => `<option value="${escapeHtml(cat)}">`).join('');
|
||||
}
|
||||
|
||||
async saveNewPrompt() {
|
||||
@@ -3562,13 +3582,13 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
html += `
|
||||
<div class="film-strip-thumbnail"
|
||||
data-action="open-film"
|
||||
data-index="${index}"
|
||||
data-prompt-id="${promptId}"
|
||||
onclick="window.admin.openFilmStripViewer(${promptId}, ${index})">
|
||||
<img src="${thumbnailUrl}"
|
||||
data-prompt-id="${promptId}">
|
||||
<img src="${escapeHtml(thumbnailUrl)}"
|
||||
alt="Image ${index + 1}"
|
||||
loading="lazy"
|
||||
onerror="this.src='${imageUrl}'; this.onerror=null;">
|
||||
data-fallback-src="${escapeHtml(imageUrl)}">
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@@ -3576,7 +3596,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
if (remaining > 0) {
|
||||
html += `
|
||||
<div class="film-strip-thumbnail film-strip-thumbnail--more"
|
||||
onclick="window.admin.viewGallery(${promptId})">
|
||||
data-action="gallery" data-prompt-id="${promptId}">
|
||||
+${remaining}
|
||||
</div>
|
||||
`;
|
||||
@@ -3961,7 +3981,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
// Display existing tags
|
||||
const tagsContainer = document.getElementById('retagExistingTags');
|
||||
tagsContainer.innerHTML = realTags.map(tag =>
|
||||
`<span class="px-2 py-1 bg-pm-accent text-pm text-xs rounded">${this.escapeHtml(tag)}</span>`
|
||||
`<span class="px-2 py-1 bg-pm-accent text-pm text-xs rounded">${escapeHtml(tag)}</span>`
|
||||
).join('');
|
||||
|
||||
// Show modal
|
||||
@@ -4093,8 +4113,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
const createTagChip = (tag, index) => `
|
||||
<span class="tag-chip">
|
||||
${this.escapeHtml(tag)}
|
||||
<span class="tag-remove" onclick="window.admin.removeReviewTag(${index})">×</span>
|
||||
${escapeHtml(tag)}
|
||||
<span class="tag-remove" data-action="remove-review-tag" data-index="${index}">×</span>
|
||||
</span>
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Dispatch for elements rendered with `data-action="..."`.
|
||||
*
|
||||
* The admin and gallery pages build HTML from server data with template
|
||||
* literals. Inline `onclick="...${value}..."` handlers would put that data
|
||||
* inside JavaScript source, where HTML escaping does not help; instead the
|
||||
* markup carries `data-*` attributes and one delegated click listener per page
|
||||
* calls `dispatch(element.dataset, handlers, element)`.
|
||||
*
|
||||
* Loadable in the browser (window.DataActions) and in Node for tests.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
function toInt(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const n = parseInt(value, 10);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMStringMap|Object|null|undefined} dataset
|
||||
* @returns {{action: string, promptId: number|null, index: number|null} & Object<string, string>}
|
||||
*/
|
||||
function readAction(dataset) {
|
||||
const data = dataset || {};
|
||||
return {
|
||||
...data,
|
||||
action: typeof data.action === "string" ? data.action : "",
|
||||
promptId: toInt(data.promptId),
|
||||
index: toInt(data.index),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMStringMap|Object} dataset - the clicked element's dataset
|
||||
* @param {Object<string, Function>} handlers - handler per action name
|
||||
* @param {...*} extra - passed to the handler after the parsed action
|
||||
* @returns {boolean} whether a handler ran
|
||||
*/
|
||||
function dispatch(dataset, handlers, ...extra) {
|
||||
const parsed = readAction(dataset);
|
||||
if (!parsed.action || !Object.prototype.hasOwnProperty.call(handlers, parsed.action)) return false;
|
||||
handlers[parsed.action](parsed, ...extra);
|
||||
return true;
|
||||
}
|
||||
|
||||
const api = { readAction, dispatch };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.DataActions = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+64
-53
@@ -10,6 +10,16 @@
|
||||
this.expandedFolders = new Set();
|
||||
this.thumbSize = 'md'; // sm, md, lg
|
||||
|
||||
// Elements rendered with data-action="..." are dispatched here instead of through
|
||||
// inline onclick handlers, so file names and URLs never land inside JavaScript source.
|
||||
this.actionHandlers = {
|
||||
"open-url": ({ url }) => window.open(url, '_blank'),
|
||||
"copy-url": ({ url, label }, el) => this.copyUrlWithFeedback(el, url, label),
|
||||
"select-suggestion": ({ tag }) => this.selectSuggestion(tag),
|
||||
"remove-tag": ({ index }) => this.removeTag(index),
|
||||
"remove-review-tag": ({ index }) => this.removeReviewTag(index),
|
||||
};
|
||||
|
||||
this.initializeEventListeners();
|
||||
this.loadFolderTree();
|
||||
this.loadImages();
|
||||
@@ -18,7 +28,20 @@
|
||||
this.checkThumbnailsAtStartup();
|
||||
}
|
||||
|
||||
handleActionClick(e) {
|
||||
const target = e.target.closest('[data-action]');
|
||||
if (target) DataActions.dispatch(target.dataset, this.actionHandlers, target);
|
||||
}
|
||||
|
||||
copyUrlWithFeedback(el, url, label) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
el.textContent = '✓ Copied!';
|
||||
setTimeout(() => { el.textContent = label; }, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
document.addEventListener('click', (e) => this.handleActionClick(e));
|
||||
document.getElementById('refreshBtn').addEventListener('click', () => this.loadImages());
|
||||
document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value)));
|
||||
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
|
||||
@@ -398,13 +421,13 @@
|
||||
return `
|
||||
<div class="image-item bg-pm-surface rounded-pm-md overflow-hidden border border-pm hover:border-pm cursor-pointer group">
|
||||
<div class="aspect-square bg-pm-primary overflow-hidden relative">
|
||||
<img src="${displayUrl}"
|
||||
alt="${this.escapeHtml(image.filename)}"
|
||||
<img src="${escapeHtml(displayUrl)}"
|
||||
alt="${escapeHtml(image.filename)}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
data-original="${image.url}"
|
||||
data-thumbnail="${image.thumbnail_url || ''}"
|
||||
data-caption="${this.escapeHtml(this.formatImageCaption(image))}"
|
||||
data-original="${escapeHtml(image.url)}"
|
||||
data-thumbnail="${escapeHtml(image.thumbnail_url || '')}"
|
||||
data-caption="${escapeHtml(this.formatImageCaption(image))}"
|
||||
data-media-type="${mediaType}"
|
||||
data-is-video="${isVideo}"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex'">
|
||||
@@ -432,7 +455,7 @@
|
||||
${isVideo ? '<div class="absolute top-2 left-2 px-2 py-1 bg-pm-error text-pm text-xs rounded" title="Video file">VIDEO</div>' : ''}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<div class="text-xs text-pm-secondary truncate" title="${this.escapeHtml(image.filename)}">${this.escapeHtml(image.filename)}</div>
|
||||
<div class="text-xs text-pm-secondary truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
|
||||
<div class="text-xs text-pm-muted">${this.formatFileSize(image.size)}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,13 +480,13 @@
|
||||
return `
|
||||
<div class="image-item bg-pm-surface rounded-pm-md border border-pm hover:border-pm cursor-pointer group flex items-center p-4">
|
||||
<div class="w-16 h-16 bg-pm-primary rounded-pm-md overflow-hidden flex-shrink-0 mr-4 relative">
|
||||
<img src="${displayUrl}"
|
||||
alt="${this.escapeHtml(image.filename)}"
|
||||
<img src="${escapeHtml(displayUrl)}"
|
||||
alt="${escapeHtml(image.filename)}"
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
data-original="${image.url}"
|
||||
data-thumbnail="${image.thumbnail_url || ''}"
|
||||
data-caption="${this.escapeHtml(this.formatImageCaption(image))}"
|
||||
data-original="${escapeHtml(image.url)}"
|
||||
data-thumbnail="${escapeHtml(image.thumbnail_url || '')}"
|
||||
data-caption="${escapeHtml(this.formatImageCaption(image))}"
|
||||
data-media-type="${mediaType}"
|
||||
data-is-video="${isVideo}"
|
||||
onerror="this.style.display='none'">
|
||||
@@ -471,9 +494,9 @@
|
||||
${isVideo ? '<div class="absolute top-1 left-1 w-4 h-3 bg-pm-error text-pm text-xs flex items-center justify-center rounded" title="Video">▶</div>' : ''}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-pm truncate">${this.escapeHtml(image.filename)}${isVideo ? ' 🎬' : ''}</div>
|
||||
<div class="text-sm font-medium text-pm truncate">${escapeHtml(image.filename)}${isVideo ? ' 🎬' : ''}</div>
|
||||
<div class="text-xs text-pm-secondary">${this.formatFileSize(image.size)} • ${new Date(image.modified_time * 1000).toLocaleDateString()}${hasThumb ? ' • Fast' : ''}${isVideo ? ' • Video' : ''}</div>
|
||||
<div class="text-xs text-pm-muted truncate">${image.relative_path}</div>
|
||||
<div class="text-xs text-pm-muted truncate">${escapeHtml(image.relative_path)}</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ml-4">
|
||||
${isVideo ? `
|
||||
@@ -705,7 +728,7 @@
|
||||
</button>
|
||||
|
||||
<!-- Main image -->
|
||||
<img src="${imageUrl}" alt="${caption}" class="max-w-full max-h-full object-contain" id="modalImage">
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(caption)}" class="max-w-full max-h-full object-contain" id="modalImage">
|
||||
</div>
|
||||
|
||||
<!-- Metadata sidebar (matching the screenshot) -->
|
||||
@@ -806,7 +829,7 @@
|
||||
|
||||
<!-- Main video -->
|
||||
<video id="modalVideo"
|
||||
src="${videoUrl}"
|
||||
src="${escapeHtml(videoUrl)}"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
controls
|
||||
${settings.videoAutoplay ? 'autoplay' : ''}
|
||||
@@ -833,7 +856,7 @@
|
||||
</svg>
|
||||
<h1 class="text-sm font-semibold text-pm">Video Info</h1>
|
||||
</div>
|
||||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors" onclick="navigator.clipboard.writeText('${videoUrl}').then(() => this.textContent = '✓ Copied!'); setTimeout(() => this.textContent = '📋 COPY URL', 1000)">
|
||||
<button class="text-xs text-pm-accent hover:text-pm-accent transition-colors" data-action="copy-url" data-url="${escapeHtml(videoUrl)}" data-label="📋 COPY URL">
|
||||
📋 COPY URL
|
||||
</button>
|
||||
</div>
|
||||
@@ -986,9 +1009,9 @@
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-pm-secondary">File Info</h2>
|
||||
<div class="text-xs text-pm-secondary font-mono bg-pm-surface p-2 rounded break-all cursor-pointer hover:bg-pm-hover"
|
||||
onclick="navigator.clipboard.writeText('${videoUrl}').then(() => this.textContent = 'Copied!'); setTimeout(() => this.textContent = '${fileName}', 1000)"
|
||||
data-action="copy-url" data-url="${escapeHtml(videoUrl)}" data-label="${escapeHtml(fileName)}"
|
||||
title="Click to copy path">
|
||||
${fileName}
|
||||
${escapeHtml(fileName)}
|
||||
</div>
|
||||
<div class="text-xs text-pm-muted">
|
||||
Type: ${fileExt} Video
|
||||
@@ -1185,7 +1208,7 @@
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-sm font-medium text-pm-secondary">File Path</h2>
|
||||
<div class="text-xs text-pm-secondary font-mono bg-pm-surface p-2 rounded break-all cursor-pointer hover:bg-pm-hover" data-copy-path title="Click to copy">
|
||||
${metadata.imagePath || 'Unknown'}
|
||||
${escapeHtml(metadata.imagePath || 'Unknown')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1195,7 +1218,7 @@
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-pm-secondary">Model:</span>
|
||||
<span class="text-pm">${metadata.checkpoint || 'Unknown'}</span>
|
||||
<span class="text-pm">${escapeHtml(metadata.checkpoint || 'Unknown')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1237,19 +1260,19 @@
|
||||
<div class="space-y-2 text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-pm-secondary">CFG SCALE:</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${metadata.cfgScale || 'Unknown'}</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.cfgScale || 'Unknown')}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-pm-secondary">STEPS:</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${metadata.steps || 'Unknown'}</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.steps || 'Unknown')}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-pm-secondary">SAMPLER:</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${metadata.sampler || 'Unknown'}</span>
|
||||
<span class="text-pm px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.sampler || 'Unknown')}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-pm-secondary">SEED:</span>
|
||||
<span class="text-pm font-mono px-2 py-1 bg-pm-surface rounded text-xs">${metadata.seed || 'Unknown'}</span>
|
||||
<span class="text-pm font-mono px-2 py-1 bg-pm-surface rounded text-xs">${escapeHtml(metadata.seed || 'Unknown')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1406,7 +1429,7 @@
|
||||
showFullPrompt(type) {
|
||||
if (this.currentMetadata) {
|
||||
const prompt = type === 'positive' ? this.currentMetadata.positivePrompt : this.currentMetadata.negativePrompt;
|
||||
const safeType = this.escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const safeType = escapeHtml(type.charAt(0).toUpperCase() + type.slice(1));
|
||||
const newWindow = window.open('', '_blank');
|
||||
const doc = newWindow.document;
|
||||
doc.open();
|
||||
@@ -1487,13 +1510,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
|
||||
return `<span class="text-pm-muted italic">${text || 'No prompt found'}</span>`;
|
||||
}
|
||||
return this.escapeHtml(text);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
return escapeHtml(text);
|
||||
}
|
||||
|
||||
unescapeHtml(text) {
|
||||
@@ -2205,7 +2222,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
${image.media_type === 'video' ? `
|
||||
<div class="w-20 h-20 bg-pm-input rounded flex items-center justify-center relative">
|
||||
${image.thumbnail_url ? `
|
||||
<img src="${image.thumbnail_url}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||||
<img src="${escapeHtml(image.thumbnail_url)}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||||
` : `
|
||||
<svg class="w-8 h-8 text-pm-secondary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||||
@@ -2214,26 +2231,26 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<div class="absolute bottom-0 right-0 bg-black/75 text-pm text-xs px-1 rounded">VIDEO</div>
|
||||
</div>
|
||||
` : `
|
||||
<img src="${image.thumbnail_url || image.url}" alt="${this.escapeHtml(image.filename)}"
|
||||
<img src="${escapeHtml(image.thumbnail_url || image.url)}" alt="${escapeHtml(image.filename)}"
|
||||
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
|
||||
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
|
||||
`}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-pm-secondary mb-2">
|
||||
<div class="font-medium truncate" title="${this.escapeHtml(image.filename)}">${this.escapeHtml(image.filename)}</div>
|
||||
<div class="font-medium truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
|
||||
<div class="text-xs text-pm-secondary mt-1">
|
||||
<span class="font-medium">Size:</span> ${this.formatFileSize(image.size)} •
|
||||
<span class="font-medium">Modified:</span> ${new Date(image.modified_time * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
<div class="text-xs text-pm-muted mt-1 truncate" title="${image.relative_path}">
|
||||
${image.relative_path}
|
||||
<div class="text-xs text-pm-muted mt-1 truncate" title="${escapeHtml(image.relative_path)}">
|
||||
${escapeHtml(image.relative_path)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="image-preview text-xs bg-pm-accent hover:bg-pm-accent-hover text-pm px-2 py-1 rounded"
|
||||
onclick="window.open('${image.url}', '_blank')">
|
||||
data-action="open-url" data-url="${escapeHtml(image.url)}">
|
||||
View Full
|
||||
</button>
|
||||
</div>
|
||||
@@ -2242,7 +2259,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
<span class="px-3 py-1 bg-pm-success text-pm text-xs rounded">KEEP (Oldest)</span>
|
||||
` : `
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="duplicate-checkbox" data-group="${groupIndex}" data-image-path="${image.path}"
|
||||
<input type="checkbox" class="duplicate-checkbox" data-group="${groupIndex}" data-image-path="${escapeHtml(image.path)}"
|
||||
class="w-4 h-4 text-pm-error bg-pm-input border-pm rounded focus:ring-pm-error">
|
||||
<span class="ml-2 text-sm text-pm-error">Delete</span>
|
||||
</label>
|
||||
@@ -2874,7 +2891,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
populateCategoryDatalist() {
|
||||
const datalist = document.getElementById('categoryList');
|
||||
datalist.innerHTML = this.addPromptState.allCategories
|
||||
.map(cat => `<option value="${this.escapeHtml(cat)}">`)
|
||||
.map(cat => `<option value="${escapeHtml(cat)}">`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -2931,8 +2948,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
.map((tag, index) => `
|
||||
<div class="px-3 py-2 hover:bg-pm-hover cursor-pointer text-pm text-sm suggestion-item"
|
||||
data-index="${index}"
|
||||
onclick="window.gallery.selectSuggestion('${this.escapeHtml(tag)}')">
|
||||
${this.escapeHtml(tag)}
|
||||
data-action="select-suggestion" data-tag="${escapeHtml(tag)}">
|
||||
${escapeHtml(tag)}
|
||||
</div>
|
||||
`)
|
||||
.join('');
|
||||
@@ -2994,8 +3011,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'tag-chip';
|
||||
chip.innerHTML = `
|
||||
${this.escapeHtml(tag)}
|
||||
<span class="tag-remove" onclick="window.gallery.removeTag(${index})">×</span>
|
||||
${escapeHtml(tag)}
|
||||
<span class="tag-remove" data-action="remove-tag" data-index="${index}">×</span>
|
||||
`;
|
||||
container.insertBefore(chip, input);
|
||||
});
|
||||
@@ -3444,7 +3461,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
this.renderReviewTags();
|
||||
} else {
|
||||
document.getElementById('reviewTagsContainer').innerHTML =
|
||||
`<div class="text-pm-error">Error: ${data.error}</div>`;
|
||||
`<div class="text-pm-error">Error: ${escapeHtml(data.error)}</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating tags:', error);
|
||||
@@ -3462,18 +3479,12 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
|
||||
container.innerHTML = this.autoTagState.currentTags.map((tag, index) => `
|
||||
<span class="tag-chip">
|
||||
${this.escapeHtml(tag)}
|
||||
<span class="tag-remove" onclick="window.gallery.removeReviewTag(${index})">×</span>
|
||||
${escapeHtml(tag)}
|
||||
<span class="tag-remove" data-action="remove-review-tag" data-index="${index}">×</span>
|
||||
</span>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
removeReviewTag(index) {
|
||||
this.autoTagState.currentTags.splice(index, 1);
|
||||
this.renderReviewTags();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* HTML escaping for the admin, gallery and metadata pages.
|
||||
*
|
||||
* Escapes the five characters that matter in both text and attribute context
|
||||
* (& < > " '), so one function is safe for `innerHTML` text nodes and for
|
||||
* `data-*` / `value` / `title` attributes alike. The old `textContent` ->
|
||||
* `innerHTML` trick left quotes untouched and let a tag such as
|
||||
* `" onmouseover="alert(1)` break out of an attribute.
|
||||
*
|
||||
* Loadable in the browser (window.escapeHtml / window.HtmlEscape) and in Node
|
||||
* for tests.
|
||||
*/
|
||||
(function (root) {
|
||||
"use strict";
|
||||
|
||||
const REPLACEMENTS = Object.freeze({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
});
|
||||
const UNSAFE = /[&<>"']/g;
|
||||
|
||||
/**
|
||||
* @param {unknown} value - anything; null/undefined become "", others go through String()
|
||||
* @returns {string} text safe to interpolate into HTML text or a quoted attribute
|
||||
*/
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value).replace(UNSAFE, (ch) => REPLACEMENTS[ch]);
|
||||
}
|
||||
|
||||
const api = { escapeHtml, escapeAttr: escapeHtml };
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = api;
|
||||
} else {
|
||||
root.HtmlEscape = api;
|
||||
root.escapeHtml = escapeHtml;
|
||||
root.escapeAttr = escapeHtml;
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+21
-37
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Gallery - Generation Data</title>
|
||||
<script src="/prompt_manager/js/theme-bridge.js"></script>
|
||||
<script src="/prompt_manager/js/html-escape.js"></script>
|
||||
<script src="/prompt_manager/js/comfy-metadata.js"></script>
|
||||
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
|
||||
<link rel="stylesheet" href="lib/tailwind/styles.css">
|
||||
@@ -32,27 +33,13 @@
|
||||
let currentWorkflowData = null;
|
||||
|
||||
function openInFileExplorer(path) {
|
||||
// For Windows file explorer
|
||||
if (navigator.platform.indexOf('Win') > -1) {
|
||||
// This will work in Electron apps or desktop environments
|
||||
try {
|
||||
window.require('child_process').exec(`explorer /select,"${path}"`);
|
||||
} catch (e) {
|
||||
// Fallback: copy path to clipboard and show alert
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
alert('File path copied to clipboard: ' + path);
|
||||
}).catch(() => {
|
||||
alert('File path: ' + path);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// For other platforms, just copy to clipboard
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
alert('File path copied to clipboard: ' + path);
|
||||
}).catch(() => {
|
||||
alert('File path: ' + path);
|
||||
});
|
||||
}
|
||||
// A browser page cannot open a file manager, so the path is copied instead.
|
||||
// (The former Electron-only branch built a shell command from the file name.)
|
||||
navigator.clipboard.writeText(path).then(() => {
|
||||
alert('File path copied to clipboard: ' + path);
|
||||
}).catch(() => {
|
||||
alert('File path: ' + path);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyToClipboard(text, button) {
|
||||
@@ -213,8 +200,8 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
<!-- File Path -->
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">File Path</h2>
|
||||
<div class="text-sm text-pm-accent hover:bg-pm-hover cursor-pointer bg-pm-surface p-2 rounded-pm-sm break-all" onclick="openInFileExplorer('${filename}')">
|
||||
${filename}
|
||||
<div class="text-sm text-pm-accent hover:bg-pm-hover cursor-pointer bg-pm-surface p-2 rounded-pm-sm break-all" data-action="open-file" data-path="${escapeHtml(filename)}">
|
||||
${escapeHtml(filename)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -223,7 +210,7 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-2">Resources used</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-pm-accent cursor-pointer">${checkpoint}</div>
|
||||
<div class="text-pm-accent cursor-pointer">${escapeHtml(checkpoint)}</div>
|
||||
<div class="text-xs text-pm-muted">ComfyUI Generated</div>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">CHECKPOINT</span>
|
||||
@@ -242,7 +229,7 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded-pm-sm max-h-32 overflow-y-auto">
|
||||
${positivePrompt.substring(0, 200)}${positivePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(positivePrompt.substring(0, 200))}${positivePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${positivePrompt.length > 200 ? '<button class="text-pm-accent hover:bg-pm-hover text-sm mt-1" onclick="showFullPrompt(\'positive\')">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -258,7 +245,7 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-pm-secondary bg-pm-surface p-3 rounded-pm-sm max-h-32 overflow-y-auto">
|
||||
${negativePrompt.substring(0, 200)}${negativePrompt.length > 200 ? '...' : ''}
|
||||
${escapeHtml(negativePrompt.substring(0, 200))}${negativePrompt.length > 200 ? '...' : ''}
|
||||
</div>
|
||||
${negativePrompt.length > 200 ? '<button class="text-pm-accent hover:bg-pm-hover text-sm mt-1" onclick="showFullPrompt(\'negative\')">Show more</button>' : ''}
|
||||
</div>
|
||||
@@ -267,12 +254,12 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-pm-secondary mb-3">Other metadata</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">CFG SCALE: ${cfgScale}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">STEPS: ${steps}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SAMPLER: ${sampler}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">CFG SCALE: ${escapeHtml(cfgScale)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">STEPS: ${escapeHtml(steps)}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SAMPLER: ${escapeHtml(sampler)}</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SEED: ${seed}</span>
|
||||
<span class="px-2 py-1 text-xs bg-pm-surface text-pm-secondary rounded-pm-sm">SEED: ${escapeHtml(seed)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -289,6 +276,9 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
dataPanel.querySelector('[data-action="open-file"]').addEventListener('click', (e) => {
|
||||
openInFileExplorer(e.currentTarget.dataset.path);
|
||||
});
|
||||
|
||||
// Store the full data for later use
|
||||
currentWorkflowData = {
|
||||
@@ -304,12 +294,6 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function showFullPrompt(type) {
|
||||
if (currentWorkflowData) {
|
||||
const prompt = type === 'positive' ? currentWorkflowData.positivePrompt : currentWorkflowData.negativePrompt;
|
||||
@@ -343,7 +327,7 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
|
||||
<head><title>ComfyUI Workflow Data</title></head>
|
||||
<body style="background: #111; color: #fff; font-family: monospace; padding: 20px;">
|
||||
<h2>ComfyUI Workflow JSON</h2>
|
||||
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${JSON.stringify(currentWorkflowData.workflow, null, 2)}</pre>
|
||||
<pre style="background: #222; padding: 15px; border-radius: 5px; overflow: auto;">${escapeHtml(JSON.stringify(currentWorkflowData.workflow, null, 2))}</pre>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
Reference in New Issue
Block a user