Setup Node refactor (the secret-bearing fields no longer exist on the
node at all):
• Removed app_key / app_secret / auth_code from INPUT_TYPES. The
Setup Node now exposes only storage_method, encryption_key_method,
and reconnect — none of which are credentials.
• Added a "Set credentials..." button on the node (LiteGraph button
widget installed via beforeRegisterNodeDef + onNodeCreated
prototype hook so it survives reload, drag-and-drop workflow
JSON, and PNG metadata restore).
• Click opens a browser-only modal with three <input type=password>
fields. Browser-native dot masking the whole time the user types
or pastes — no canvas-rendered plaintext, no shoulder-surf.
• Modal Save POSTs values to a new same-origin route
/dropsend/setup/stash and closes. setup() consumes the stash
entry on the next Queue (one-shot, keyed by client_id, 60-second
TTL, 32-entry cap with oldest-eviction).
• Architectural consequence: secrets never become widget values, so
they cannot enter saved workflow JSON, ComfyUI's localStorage
auto-save, PNG metadata, copy-pasted nodes, or PromptServer.history
(and therefore the unauthenticated /history HTTP endpoint).
Auto-curl auth flow (one-click second paste):
• After Run 1 prints the OAuth URL banner, setup() sends a
sid-targeted dropsend_credentials_needed WebSocket event.
• JS handler auto-opens the entry modal in the originating browser
so the user can paste auth_code as soon as Dropbox shows it,
without clicking the button between runs.
• Defense in depth: client_id is echoed in the WS payload and the
JS handler verifies it matches api.clientId before opening.
Security hardening on /dropsend/setup/stash:
• Same-origin enforcement: Origin header must match request.host.
Closes cross-origin CSRF from another browser tab even when an
attacker has sniffed a victim's client_id.
• Content-Type lockdown: rejects anything other than
application/json with HTTP 415. Closes the confused-deputy class
where form-encoded bodies could be parsed as JSON.
• Live-session check: client_id must correspond to an active
PromptServer.instance.sockets entry. Returns 403 otherwise.
Closes the stash-poisoning vector where an attacker who guesses
a victim's client_id could pre-populate credentials.
• Per-IP rate limit: 30 POSTs / 10-second sliding window, returns
429 over budget. Defense against stash-spam DoS attempting to
evict a legitimate user's entry.
• Body cap (32 KB), client_id regex validation, JSON parse errors
return 400, empty payloads dropped.
• Both rate-limit and live-session checks empirically verified via
curl smoke tests.
Reconnect now revokes the Dropbox refresh token at the source:
• reconnect=True populates DropboxAuthManager from os.environ and
calls reset(revoke_token=True).
• POST /2/auth/token/revoke with an 8-second timeout so a Dropbox
outage cannot hang the operator mid-reconnect. Local cleanup
proceeds regardless of revocation outcome.
• Revocation outcome is now logged at logger.error level (not just
print) and the failure message tells the operator to manually
disconnect at https://www.dropbox.com/account/connected_apps so
a silent revocation failure cannot leave a still-valid token at
Dropbox.
Logging:
• Single FileHandler attached to the root logger from __init__.py,
pointed at a plugin-relative dropsend.log. Replaces the four
duplicated logging.basicConfig() blocks in monitor_output.py /
encrypt_file.py / dropbox_upload.py / dropsend_uploader_node.py
that were dead code (basicConfig is a no-op once ComfyUI has
already configured root). Log records from every DropSend module
now actually persist to disk.
• Race-free creation: pre-create with os.open(O_CREAT | O_WRONLY |
O_APPEND, 0o600) before FileHandler attaches, follow-up chmod as
belt-and-suspenders. Closes the brief default-umask window
between FileHandler creating the file and post-hoc chmod.
• Rotation via RotatingFileHandler (5 MB per file, 3 backups, ~20
MB max). Bounds disk use on long-lived hosts.
• dropbox_auth_manager.py now has its own logger so revocation
successes/failures land in dropsend.log alongside stdout.
Tests (tests/ directory, unittest, no extra deps):
• test_safe_paths.py — 23 tests covering symlink rejection, path-
traversal rejection, allowlist behaviour, dropbox_dest validation
(NUL bytes, missing leading slash, parent-traversal segments),
and resolve_safe_watch_folder edge cases. safe_paths is the only
barrier between remote workflow inputs and arbitrary-file-read
on the host; this is regression coverage for that barrier.
• test_setup_node_security.py — static AST analysis of
dropsend_setup_node.py asserting that every send_sync call for a
credential event passes a non-None sid AND sits inside an
"if sid:" guard. Catches future regressions of the original
WebSocket-broadcast vulnerability deterministically. Also asserts
INPUT_TYPES never re-introduces app_key/app_secret/auth_code.
WebSocket-broadcast guard maintained on all three event sites
(dropsend_credentials_ready, dropbox_reconnect_complete,
dropsend_credentials_needed) — refuse to send when client_id is None
to prevent send_sync(..., None) from broadcasting credentials to
every connected browser tab.
README:
• New Changelog section documenting the v1.1.0 security advisory
(workflow-persistence + WS-broadcast leaks in earlier versions)
and the fixes shipped in this release.
• New "Bind ComfyUI to localhost or front it with auth" Best
Practice item (item 4) explicitly calling out that ComfyUI's
/prompt endpoint is unauthenticated.
• New "Encryption key rotation" subsection covering symmetric-key
implications, leaked-key recovery, and how to generate a new key.
• New "Uninstalling / disconnecting" subsection with step-by-step
instructions for revoking Dropbox app, resetting app secret,
removing platform secrets, and handling existing .enc files.
• Threat model reflects same-origin/Content-Type checks, log
rotation, plugin-directory metadata-leak note for multi-tenant
hosts, reconnect-revocation silent-failure note, and Dropbox
SDK error-message log review.
.gitignore:
• Expanded to cover *.bak, *.enc, *.key/.pem/.p12/.pfx, .idea/,
.pytest_cache/, build artifacts, Thumbs.db/Desktop.ini.
Bundled in this minor release:
• Single-Observer fix for the macOS _fsevents conflict that
previously prevented .enc files from being picked up by the
upload watcher.
• folder_format dropdown for AutoUploader (project_name default
plus five worldwide date formats), with two-base persistence so
switching between project-name and dated modes preserves both
patterns.
• RUNNING banner with per-setting plain-English explanations.
• Branded watching/encrypted/sent log emojis.
• Per-shell copy-paste commands and echo verification for the
COMFYUI_DROPSEND_ALLOW_SETUP gate.
583 lines
26 KiB
JavaScript
583 lines
26 KiB
JavaScript
// dropbox_oauth.js — ComfyUI extension for the DropSend Setup Node.
|
||
//
|
||
// Architecture summary
|
||
// --------------------
|
||
// The Setup Node deliberately has NO secret-bearing inputs in its
|
||
// Python INPUT_TYPES. app_key, app_secret, and auth_code are entered
|
||
// only via a browser-only modal launched from the "Set credentials…"
|
||
// button this extension installs on the node. The modal POSTs the
|
||
// values directly to /dropsend/setup/stash (a same-origin route
|
||
// registered by dropsend_setup_node.py) and closes. setup() consumes
|
||
// the stash entry on the next Queue.
|
||
//
|
||
// What this guarantees
|
||
// • Secrets never enter any LiteGraph widget value, so they cannot
|
||
// be serialized to workflow JSON, ComfyUI's localStorage auto-save,
|
||
// PNG metadata, copy-pasted nodes, or PromptServer.history /
|
||
// /history.
|
||
// • Browser-native masking (input type=password) covers shoulder-
|
||
// surfing during typing.
|
||
// • Modal closes after Save; no in-process JS cache. User pastes
|
||
// fresh values each time the modal opens — they don't persist
|
||
// across modal sessions.
|
||
//
|
||
// Auto-trigger UX
|
||
// ---------------
|
||
// After Run 1 prints the OAuth URL banner, dropsend_setup_node.py
|
||
// sends a "dropsend_credentials_needed" WebSocket event to the
|
||
// originating client_id only. Receiving that event auto-opens the
|
||
// entry modal so the user can paste the auth_code (plus re-paste
|
||
// app_key/app_secret) without clicking the button between runs.
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Constants and small helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Node-type registered in NODE_CLASS_MAPPINGS in dropsend_setup_node.py.
|
||
const SETUP_NODE_TYPE = "DropSendSetup";
|
||
|
||
// Track OAuth popup window so a second click reuses/replaces the first.
|
||
let oauthPopup = null;
|
||
|
||
// Find every Setup Node currently in the graph (typically just one).
|
||
function findSetupNodes() {
|
||
try {
|
||
return app.graph.nodes.filter(n => n.type === SETUP_NODE_TYPE);
|
||
} catch (e) {
|
||
console.warn("[DropSendNode] Could not enumerate graph nodes:", e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// "Set credentials…" button widget on the Setup Node
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Add the button widget to a Setup Node instance. Idempotent — safe
|
||
// to call repeatedly from any code path (prototype.onNodeCreated,
|
||
// loadedGraphNode, setup-time sweep) without producing duplicate
|
||
// buttons.
|
||
//
|
||
// Critical detail: after addWidget the node's bounding box must be
|
||
// recomputed via setSize(computeSize()), otherwise the new widget is
|
||
// drawn outside the existing draw area and looks "missing." This is
|
||
// the failure mode that hit when nodeCreated installed the button
|
||
// after ComfyUI had already laid out the node for its three other
|
||
// widgets.
|
||
function ensureCredentialButton(node) {
|
||
if (!node || node.type !== SETUP_NODE_TYPE) return;
|
||
if (node.__dropsendButtonAdded) return;
|
||
if (typeof node.addWidget !== "function") {
|
||
console.warn("[DropSendNode] node.addWidget unavailable; cannot install Set credentials button");
|
||
return;
|
||
}
|
||
node.addWidget("button", "Set credentials…", null, () => {
|
||
openCredentialEntryModal({ reason: "user-clicked" });
|
||
});
|
||
node.__dropsendButtonAdded = true;
|
||
try {
|
||
if (typeof node.computeSize === "function" && typeof node.setSize === "function") {
|
||
node.setSize(node.computeSize());
|
||
}
|
||
} catch (e) {
|
||
console.warn("[DropSendNode] could not recompute node size after adding button:", e);
|
||
}
|
||
try { app.graph.setDirtyCanvas(true, true); } catch (_) {}
|
||
console.log(`[DropSendNode] Installed Set-credentials button on ${SETUP_NODE_TYPE}`);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Credential ENTRY modal (the new input surface)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// This is the modal the user types secrets INTO. Distinct from
|
||
// showCredentialModal below, which is the OUTPUT modal that displays
|
||
// the four delivered credentials at the end of a successful Setup run.
|
||
|
||
function openCredentialEntryModal({ reason }) {
|
||
// If an entry modal is already open (e.g. user clicked the button
|
||
// twice, or auto-trigger fired while one was already open), just
|
||
// bring it to focus instead of stacking.
|
||
const existing = document.getElementById("dropsend-credentials-entry-modal");
|
||
if (existing) {
|
||
const firstInput = existing.querySelector("input");
|
||
if (firstInput) firstInput.focus();
|
||
return;
|
||
}
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "dropsend-credentials-entry-modal";
|
||
overlay.style.cssText = [
|
||
"position:fixed", "inset:0",
|
||
"background:rgba(0,0,0,0.7)",
|
||
"z-index:10001",
|
||
"display:flex", "align-items:center", "justify-content:center",
|
||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'
|
||
].join(";");
|
||
|
||
const dialog = document.createElement("div");
|
||
dialog.style.cssText = [
|
||
"background:#1e1e1e", "color:#eaeaea",
|
||
"border-radius:12px", "padding:24px",
|
||
"max-width:520px", "width:90%",
|
||
"box-shadow:0 20px 60px rgba(0,0,0,0.5)",
|
||
"box-sizing:border-box"
|
||
].join(";");
|
||
|
||
const header = document.createElement("div");
|
||
header.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:8px";
|
||
const title = document.createElement("h2");
|
||
title.textContent = "Set DropSend Credentials";
|
||
title.style.cssText = "margin:0;font-size:1.2rem;color:#fff";
|
||
const closeX = document.createElement("button");
|
||
closeX.textContent = "×";
|
||
closeX.setAttribute("aria-label", "Close");
|
||
closeX.style.cssText = "background:none;border:none;color:#999;font-size:1.5rem;cursor:pointer;padding:0 8px;line-height:1";
|
||
closeX.onclick = () => overlay.remove();
|
||
header.appendChild(title);
|
||
header.appendChild(closeX);
|
||
dialog.appendChild(header);
|
||
|
||
const intro = document.createElement("p");
|
||
intro.style.cssText = "color:#bbb;font-size:0.85rem;line-height:1.5;margin:0 0 16px 0";
|
||
intro.textContent =
|
||
reason === "auto-after-oauth-url"
|
||
? "Authorization URL printed in the ComfyUI terminal. Authorize at Dropbox, copy the auth code, then paste app_key + app_secret + auth_code below and click Save."
|
||
: "Paste your Dropbox app_key and app_secret. Leave auth_code empty for now; you'll paste it later after authorizing at Dropbox. Values are sent to the server (browser only, never written to disk in display_only mode) and are NOT saved by ComfyUI in any workflow JSON, PNG metadata, or browser cache.";
|
||
dialog.appendChild(intro);
|
||
|
||
// Three password inputs.
|
||
const fields = [
|
||
{ name: "app_key", label: "App Key" },
|
||
{ name: "app_secret", label: "App Secret" },
|
||
{ name: "auth_code", label: "Auth Code (paste after authorizing at Dropbox)" },
|
||
];
|
||
const inputs = {};
|
||
fields.forEach(({ name, label }) => {
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "margin-bottom:12px";
|
||
|
||
const lbl = document.createElement("label");
|
||
lbl.textContent = label;
|
||
lbl.style.cssText = "display:block;font-size:0.8rem;color:#aaa;margin-bottom:4px;letter-spacing:0.02em";
|
||
row.appendChild(lbl);
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "password";
|
||
input.autocomplete = "off";
|
||
input.spellcheck = false;
|
||
input.value = "";
|
||
input.style.cssText = "width:100%;box-sizing:border-box;padding:8px;font-family:Menlo,Monaco,monospace;font-size:0.9rem;background:#2a2a2a;color:#eaeaea;border:1px solid #444;border-radius:4px";
|
||
row.appendChild(input);
|
||
|
||
dialog.appendChild(row);
|
||
inputs[name] = input;
|
||
});
|
||
|
||
// Status line for inline error messages from the Save handler.
|
||
const status = document.createElement("p");
|
||
status.style.cssText = "color:#f87171;font-size:0.8rem;margin:6px 0 0 0;min-height:1em";
|
||
status.textContent = "";
|
||
dialog.appendChild(status);
|
||
|
||
const footer = document.createElement("div");
|
||
footer.style.cssText = "display:flex;gap:10px;margin-top:16px;padding-top:12px;border-top:1px solid #333";
|
||
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.textContent = "Cancel";
|
||
cancelBtn.style.cssText = "flex:1;padding:9px;background:#444;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem";
|
||
cancelBtn.onclick = () => overlay.remove();
|
||
|
||
const saveBtn = document.createElement("button");
|
||
saveBtn.textContent = "Save";
|
||
saveBtn.style.cssText = "flex:1;padding:9px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem";
|
||
saveBtn.onclick = async () => {
|
||
const payload = {
|
||
app_key: inputs.app_key.value || "",
|
||
app_secret: inputs.app_secret.value || "",
|
||
auth_code: inputs.auth_code.value || "",
|
||
};
|
||
if (!payload.app_key && !payload.app_secret && !payload.auth_code) {
|
||
status.textContent = "Nothing to save. Paste at least app_key + app_secret.";
|
||
return;
|
||
}
|
||
const clientId = api && api.clientId ? api.clientId : null;
|
||
if (!clientId) {
|
||
status.textContent = "Browser not yet connected to ComfyUI. Wait a moment and try again.";
|
||
return;
|
||
}
|
||
saveBtn.disabled = true;
|
||
cancelBtn.disabled = true;
|
||
try {
|
||
const resp = await fetch("/dropsend/setup/stash", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ client_id: clientId, ...payload }),
|
||
});
|
||
if (!resp.ok) throw new Error("server returned " + resp.status);
|
||
} catch (e) {
|
||
console.error("[DropSendNode] Stash POST failed:", e);
|
||
status.textContent = "Could not save: " + e.message;
|
||
saveBtn.disabled = false;
|
||
cancelBtn.disabled = false;
|
||
return;
|
||
}
|
||
// Wipe the input values from DOM the moment Save succeeds —
|
||
// even though the modal is about to be removed, we don't want
|
||
// the values to sit in input.value while the GC catches up.
|
||
Object.values(inputs).forEach(i => { i.value = ""; });
|
||
overlay.remove();
|
||
console.log("[DropSendNode] Stashed credentials via entry modal; click Queue to run setup");
|
||
};
|
||
|
||
footer.appendChild(cancelBtn);
|
||
footer.appendChild(saveBtn);
|
||
dialog.appendChild(footer);
|
||
|
||
// Submit on Enter from any input.
|
||
Object.values(inputs).forEach(input => {
|
||
input.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") { ev.preventDefault(); saveBtn.click(); }
|
||
if (ev.key === "Escape") { ev.preventDefault(); cancelBtn.click(); }
|
||
});
|
||
});
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
// Auto-focus the first empty input — usually app_key on initial
|
||
// open, auth_code if app_key is already filled (no longer applies
|
||
// here since we don't pre-fill, but the same logic is harmless).
|
||
setTimeout(() => {
|
||
const firstEmpty = fields.map(f => inputs[f.name]).find(i => !i.value);
|
||
if (firstEmpty) firstEmpty.focus();
|
||
}, 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// OAuth popup window (delegate to Dropbox)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
window.openDropboxOAuth = function (url) {
|
||
console.log("[DropSendNode] Opening OAuth popup:", url);
|
||
if (oauthPopup && !oauthPopup.closed) oauthPopup.close();
|
||
const popup = window.open(
|
||
url,
|
||
"dropbox_oauth",
|
||
"width=500,height=700,scrollbars=yes,resizable=yes,status=no,location=no,toolbar=no,menubar=no"
|
||
);
|
||
if (popup) {
|
||
oauthPopup = popup;
|
||
popup.focus();
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
console.log("[DropSendNode] OAuth popup closed");
|
||
clearInterval(checkClosed);
|
||
oauthPopup = null;
|
||
}
|
||
}, 500);
|
||
return popup;
|
||
}
|
||
console.error("[DropSendNode] OAuth popup blocked by browser?");
|
||
return null;
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Credential OUTPUT modal (the existing display_only delivery surface)
|
||
// ---------------------------------------------------------------------------
|
||
//
|
||
// Renders the four credentials delivered by the server after a
|
||
// successful exchange. Browser-only — never persisted, discarded
|
||
// when closed.
|
||
|
||
function showCredentialModal(creds) {
|
||
const existing = document.getElementById("dropsend-credentials-modal");
|
||
if (existing) existing.remove();
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "dropsend-credentials-modal";
|
||
overlay.style.cssText = [
|
||
"position:fixed", "inset:0",
|
||
"background:rgba(0,0,0,0.7)",
|
||
"z-index:10000",
|
||
"display:flex", "align-items:center", "justify-content:center",
|
||
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif'
|
||
].join(";");
|
||
|
||
const dialog = document.createElement("div");
|
||
dialog.style.cssText = [
|
||
"background:#1e1e1e", "color:#eaeaea",
|
||
"border-radius:12px", "padding:24px",
|
||
"max-width:600px", "width:90%", "max-height:85vh",
|
||
"overflow-y:auto",
|
||
"box-shadow:0 20px 60px rgba(0,0,0,0.5)",
|
||
"box-sizing:border-box"
|
||
].join(";");
|
||
|
||
const header = document.createElement("div");
|
||
header.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:12px";
|
||
const title = document.createElement("h2");
|
||
title.textContent = "DropSend Credentials";
|
||
title.style.cssText = "margin:0;font-size:1.25rem;color:#fff";
|
||
const closeX = document.createElement("button");
|
||
closeX.textContent = "×";
|
||
closeX.setAttribute("aria-label", "Close");
|
||
closeX.style.cssText = "background:none;border:none;color:#999;font-size:1.5rem;cursor:pointer;padding:0 8px;line-height:1";
|
||
closeX.onclick = () => overlay.remove();
|
||
header.appendChild(title);
|
||
header.appendChild(closeX);
|
||
dialog.appendChild(header);
|
||
|
||
const intro = document.createElement("p");
|
||
intro.style.cssText = "color:#bbb;font-size:0.9rem;line-height:1.5;margin:0 0 18px 0";
|
||
intro.textContent =
|
||
"These values are shown only in this browser and will be discarded when you close " +
|
||
"this dialog. They are NOT written to disk in this mode. Save each value somewhere " +
|
||
"safe now (password manager, secure note). Then configure them as your platform's " +
|
||
"secrets (RunPod Secrets, Docker env, systemd EnvironmentFile). Most cloud platforms " +
|
||
"require a pod or container restart for new secrets to take effect. Running the " +
|
||
"AutoUploader will fail until that restart completes.";
|
||
dialog.appendChild(intro);
|
||
|
||
const flashCopied = (btn) => {
|
||
const original = btn.textContent;
|
||
btn.textContent = "✓";
|
||
setTimeout(() => { btn.textContent = original; }, 1200);
|
||
};
|
||
|
||
Object.entries(creds).forEach(([name, value]) => {
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "margin-bottom:14px";
|
||
|
||
const labelRow = document.createElement("div");
|
||
labelRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:4px";
|
||
const label = document.createElement("label");
|
||
label.textContent = name;
|
||
label.style.cssText = "font-weight:600;font-size:0.85rem;color:#aaa;letter-spacing:0.02em";
|
||
|
||
const copyBtn = document.createElement("button");
|
||
copyBtn.textContent = "Copy";
|
||
copyBtn.style.cssText = "padding:4px 12px;font-size:0.8rem;background:#3b82f6;color:#fff;border:none;border-radius:4px;cursor:pointer";
|
||
copyBtn.onclick = async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(value);
|
||
flashCopied(copyBtn);
|
||
} catch (e) {
|
||
input.select();
|
||
document.execCommand("copy");
|
||
flashCopied(copyBtn);
|
||
}
|
||
};
|
||
labelRow.appendChild(label);
|
||
labelRow.appendChild(copyBtn);
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.value = value;
|
||
input.readOnly = true;
|
||
input.style.cssText = "width:100%;box-sizing:border-box;padding:8px;font-family:Menlo,Monaco,monospace;font-size:0.85rem;background:#2a2a2a;color:#eaeaea;border:1px solid #444;border-radius:4px";
|
||
input.onclick = () => input.select();
|
||
input.onfocus = () => input.select();
|
||
|
||
row.appendChild(labelRow);
|
||
row.appendChild(input);
|
||
dialog.appendChild(row);
|
||
});
|
||
|
||
const footer = document.createElement("div");
|
||
footer.style.cssText = "display:flex;gap:10px;margin-top:18px;padding-top:14px;border-top:1px solid #333";
|
||
|
||
const copyAllBtn = document.createElement("button");
|
||
copyAllBtn.textContent = "Copy all as NAME=value";
|
||
copyAllBtn.style.cssText = "flex:1;padding:10px;background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem";
|
||
copyAllBtn.onclick = async () => {
|
||
const text = Object.entries(creds).map(([k, v]) => `${k}=${v}`).join("\n");
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
flashCopied(copyAllBtn);
|
||
} catch (e) {
|
||
console.error("[DropSendNode] Clipboard write failed:", e);
|
||
alert("Clipboard write failed; select and copy values manually.");
|
||
}
|
||
};
|
||
|
||
const doneBtn = document.createElement("button");
|
||
doneBtn.textContent = "I have copied them";
|
||
doneBtn.style.cssText = "flex:1;padding:10px;background:#444;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:0.9rem";
|
||
doneBtn.onclick = () => overlay.remove();
|
||
|
||
footer.appendChild(copyAllBtn);
|
||
footer.appendChild(doneBtn);
|
||
dialog.appendChild(footer);
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Extension registration
|
||
// ---------------------------------------------------------------------------
|
||
|
||
app.registerExtension({
|
||
name: "DropSendNode.OAuthHandler",
|
||
|
||
async setup() {
|
||
console.log("[DropSendNode] OAuth extension loaded");
|
||
|
||
// Defensive sweep: if ComfyUI restored a workflow before our
|
||
// hooks were attached, install the button on any Setup Node
|
||
// already in the graph at this point.
|
||
try {
|
||
findSetupNodes().forEach(n => ensureCredentialButton(n));
|
||
} catch (e) {
|
||
console.warn("[DropSendNode] setup-time sweep failed:", e);
|
||
}
|
||
|
||
// Reconnect-complete: server cleared credentials and wants the
|
||
// browser to refresh so the auth UI is back in its initial state.
|
||
api.addEventListener("dropbox_reconnect_complete", (event) => {
|
||
console.log("[DropSendNode] dropbox_reconnect_complete:", event.detail);
|
||
const data = event.detail;
|
||
if (data && data.success) {
|
||
this.handleReconnectSuccess();
|
||
}
|
||
});
|
||
|
||
// Credentials-needed: server printed the OAuth URL and is
|
||
// signaling the browser to open the entry modal so the user
|
||
// can paste the auth_code (and re-paste app_key/app_secret)
|
||
// as soon as Dropbox shows them the code.
|
||
api.addEventListener("dropsend_credentials_needed", (event) => {
|
||
console.log("[DropSendNode] dropsend_credentials_needed:", event.detail);
|
||
const data = event.detail || {};
|
||
const expectedClientId = data.client_id;
|
||
const ourClientId = api && typeof api.clientId !== "undefined" ? api.clientId : null;
|
||
if (expectedClientId && ourClientId && expectedClientId !== ourClientId) {
|
||
console.warn(
|
||
"[DropSendNode] Ignoring credentials-needed event: client_id mismatch (got " +
|
||
String(expectedClientId) + ", expected " + String(ourClientId) + ")"
|
||
);
|
||
return;
|
||
}
|
||
openCredentialEntryModal({ reason: "auto-after-oauth-url" });
|
||
});
|
||
|
||
// Credentials-ready: server delivered the four output values
|
||
// for display_only mode. Show the OUTPUT modal.
|
||
api.addEventListener("dropsend_credentials_ready", (event) => {
|
||
console.log("[DropSendNode] dropsend_credentials_ready");
|
||
const data = event.detail;
|
||
if (!data || !data.credentials || typeof data.credentials !== "object") {
|
||
console.error("[DropSendNode] credentials_ready payload missing/malformed", event);
|
||
return;
|
||
}
|
||
const expectedClientId = data.client_id;
|
||
const ourClientId = api && typeof api.clientId !== "undefined" ? api.clientId : null;
|
||
if (ourClientId == null) {
|
||
console.warn(
|
||
"[DropSendNode] api.clientId not available, cannot defense-in-depth verify; " +
|
||
"trusting the server's sid-targeted delivery."
|
||
);
|
||
} else if (expectedClientId && expectedClientId !== ourClientId) {
|
||
console.warn(
|
||
"[DropSendNode] Ignoring credentials event: client_id mismatch (got " +
|
||
String(expectedClientId) + ", expected " + String(ourClientId) + ")"
|
||
);
|
||
return;
|
||
}
|
||
// If an entry modal is still open from before, close it —
|
||
// we don't need it any more, the values were consumed and
|
||
// the OUTPUT modal is about to render.
|
||
const entry = document.getElementById("dropsend-credentials-entry-modal");
|
||
if (entry) entry.remove();
|
||
showCredentialModal(data.credentials);
|
||
});
|
||
},
|
||
|
||
resetReconnectFields() {
|
||
try {
|
||
findSetupNodes().forEach(node => {
|
||
if (!node.widgets) return;
|
||
const reconnectWidget = node.widgets.find(w => w.name === "reconnect");
|
||
if (reconnectWidget && reconnectWidget.value === true) {
|
||
reconnectWidget.value = false;
|
||
if (node.onWidgetChanged) node.onWidgetChanged("reconnect", false);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.log("[DropSendNode] Could not reset reconnect fields:", e);
|
||
}
|
||
},
|
||
|
||
handleReconnectSuccess() {
|
||
console.log("[DropSendNode] Reconnect success - refreshing UI to show auth fields");
|
||
this.resetReconnectFields();
|
||
// Close any open entry modal — the prior session is gone.
|
||
const entry = document.getElementById("dropsend-credentials-entry-modal");
|
||
if (entry) entry.remove();
|
||
setTimeout(() => {
|
||
console.log("[DropSendNode] Reloading after reconnect");
|
||
window.location.reload();
|
||
}, 1000);
|
||
},
|
||
|
||
nodeCreated(node) {
|
||
ensureCredentialButton(node);
|
||
},
|
||
|
||
// Fires when a node is restored from saved state — auto-save in
|
||
// localStorage on page reload, dragged workflow JSON, dragged PNG
|
||
// with embedded workflow metadata. Without this, the button
|
||
// wouldn't be added to nodes that already existed when the
|
||
// workflow was loaded.
|
||
loadedGraphNode(node, _app) {
|
||
ensureCredentialButton(node);
|
||
},
|
||
|
||
// Install the Set-credentials button on every instance of the
|
||
// Setup Node. We override prototype.onNodeCreated here (rather
|
||
// than relying solely on the nodeCreated extension hook) because
|
||
// it runs in-context with the node's construction, so the
|
||
// button is added before ComfyUI computes the node's initial
|
||
// size — guaranteeing the button is inside the drawn bounds.
|
||
//
|
||
// Also detects the OAuth URL printed by the Python setup() and
|
||
// auto-opens it in a popup window.
|
||
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
|
||
if (nodeData.name !== SETUP_NODE_TYPE) return;
|
||
console.log(`[DropSendNode] Registered ${SETUP_NODE_TYPE} enhancement`);
|
||
|
||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
const r = origOnNodeCreated ? origOnNodeCreated.apply(this, arguments) : undefined;
|
||
try {
|
||
ensureCredentialButton(this);
|
||
} catch (e) {
|
||
console.warn("[DropSendNode] ensureCredentialButton via prototype hook failed:", e);
|
||
}
|
||
return r;
|
||
};
|
||
|
||
const originalExecuted = nodeType.prototype.onExecuted;
|
||
nodeType.prototype.onExecuted = function (message) {
|
||
try {
|
||
if (message && message.text && message.text[0]) {
|
||
const text = message.text[0];
|
||
if (text.includes("Dropbox OAuth Ready!")) {
|
||
const m = text.match(/(https:\/\/www\.dropbox\.com\/oauth2\/authorize[^\s]+)/);
|
||
if (m) {
|
||
setTimeout(() => window.openDropboxOAuth(m[1]), 500);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn("[DropSendNode] onExecuted hook error:", e);
|
||
}
|
||
if (originalExecuted) return originalExecuted.apply(this, arguments);
|
||
};
|
||
},
|
||
});
|