Files
MachinePainting 4f85fcadfa v1.1.0: Security hardening parity with DropSend (untested)
Mirrors the security architecture of ComfyUI_DropSendNode v1.1.x to close
the same vulnerability classes. The actual Google OAuth grant path is
unchanged from v1.0.1 (same Flow.from_client_config, same OOB redirect,
same fetch_token call), so existing users' OAuth setup continues to work.
What changed is the wrapper around it. Note: this release has not been
runtime-tested end to end; the security logic is a direct port of the
tested DropSend implementation.

Setup Node refactor:
- Removed google_client_id, google_client_secret, auth_code, and the
  service-account JSON from INPUT_TYPES. They no longer enter
  PromptServer.history, workflow JSON, PNG metadata, localStorage, or
  copy-pasted nodes.
- Added a browser-only modal (web/drivesend_oauth.js) and a same-origin
  POST route /drivesend/setup/stash for entry. Hardening: origin check,
  live-WebSocket gate, per-IP rate limit, 32 KB body cap, JSON content
  type lockdown, 60-second TTL, 32-entry capacity, lock-protected access.
- Added COMFYUI_DRIVESEND_ALLOW_SETUP=1 opt-in gate. Without it the node
  refuses to write or clear credentials, blocking remote workflow
  submitters from hijacking or wiping the operator's Drive connection.
- Removed plaintext stdout prints of GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
  / GOOGLE_REFRESH_TOKEN / COMFYUI_ENCRYPTION_KEY. Replaced with a
  redacted (last-4-chars) banner.
- WebSocket credential delivery is sid-targeted, never broadcast. Refuses
  to send when client_id is None and surfaces a clear error. JS handler
  verifies client_id matches api.clientId before rendering.
- Service-account JSON is entered via a textarea in the same modal,
  base64-encoded into GOOGLE_SERVICE_ACCOUNT_JSON for delivery.

File-system hardening:
- .env and token.json now created via os.open(O_WRONLY|O_CREAT|O_TRUNC,
  0o600), race-free, instead of open('w') with default umask (0o644).
- Added safe_paths.py: clamps watch_folder to ComfyUI's output tree
  (with COMFYUI_DRIVESEND_ALLOWED_WATCH_PATHS opt-in for additional
  roots), rejects symlinks in event paths, validates Drive folder IDs.
- AutoUploader now runs every event path through safe_paths.is_safe_event_path.

Logging:
- Centralized logging in __init__.py with race-free 0o600 creation of
  drivesend.log on the root logger. Removed module-level
  logging.basicConfig duplicates from monitor_output.py, encrypt_file.py,
  drivesend_uploader_node.py.
- Verified: no logger or print call in the codebase emits an access
  token, refresh token, client secret, service-account JSON body, or
  encryption key value.

.gitignore additions:
- token.json, client_secret.json, service_account.json (Google credential
  caches, previously not ignored).
- Editor backups: *.bak, *.backup, *~, *.swp, *.swo, .idea/.
- Encrypted artifacts: *.enc.
- Stray credential material: *.key, *.pem, *.p12, *.pfx.
- .claude/, .pytest_cache/, .coverage, htmlcov/, build artifacts,
  Thumbs.db, Desktop.ini.

README rewrite:
- Linear three-step structure (Google Cloud setup, Setup Node, AutoUploader).
- Cloud / Local / Service-Account sections for the Setup Node.
- Threat model section documenting all enforced protections.
- "Setup is disabled" and "Browser delivery refused" troubleshooting.
- No em dashes per project style.

Tests:
- tests/test_security.py with five regression tests covering: send_sync
  is never called with sid=None for credential events; payload echoes
  client_id for JS-side verification; .env file mode is exactly 0o600;
  client_id pattern rejects malformed input; stash entries expire after
  TTL. Tests run without google-auth, googleapiclient, or watchdog
  installed via ghost modules.

Version: 1.0.1 -> 1.1.0
2026-05-09 07:52:42 -07:00

864 lines
37 KiB
Python

"""
DriveSend Setup Node
Handles Google Drive API authentication setup for both OAuth and Service Account methods.
Architecture: this node has NO credential-bearing inputs. client_id,
client_secret, and auth_code are entered only via a browser-only modal
launched from the JS extension's "Set credentials..." button. The modal
POSTs values directly to the same-origin route /drivesend/setup/stash
and clears its inputs. setup() consumes the per-session stash entry on
the next Queue. This keeps every credential out of PromptServer.history
(served on the unauthenticated /history endpoint), workflow JSON, PNG
metadata, ComfyUI's localStorage auto-save, and copy-pasted nodes.
"""
import os
import re
import time
import json
import base64
import threading
import logging
from pathlib import Path
from cryptography.fernet import Fernet
from dotenv import dotenv_values, load_dotenv
logger = logging.getLogger(__name__)
NODE_DIR = Path(__file__).parent
# ---------------------------------------------------------------------------
# Out-of-band secret transport
# ---------------------------------------------------------------------------
# ComfyUI persists every queued prompt's input JSON in
# PromptServer.history, served on the unauthenticated /history endpoint.
# If the Setup Node accepted client_id/client_secret/auth_code as
# workflow inputs, those values would be visible there for the lifetime
# of the history entry. To close that path: the JS extension shows a
# browser-only modal, POSTs the secret values to /drivesend/setup/stash
# (this module's route), clears the inputs, then lets the queue go
# through. The prompt JSON ComfyUI stores in history therefore has no
# credential fields. setup() pulls the real values from the stash
# (one-shot, keyed by originating client_id).
#
# Entries auto-expire after _SETUP_STASH_TTL_SEC so a stash POST that's
# never consumed (user closed the tab between Save and Queue, etc.)
# does not sit in memory.
# ---------------------------------------------------------------------------
_setup_secret_stash = {}
_setup_secret_lock = threading.Lock()
_SETUP_STASH_TTL_SEC = 60
_SETUP_STASH_MAX_ENTRIES = 32
_CLIENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
# Per-IP rate limit on the stash route, defense in depth against
# stash-spam attempts to evict a legitimate user's entry.
_STASH_RL_WINDOW_SEC = 10
_STASH_RL_MAX_PER_WINDOW = 30
_stash_rl_state = {}
_stash_rl_lock = threading.Lock()
def _stash_rl_check(remote_ip):
if not remote_ip:
return True
now = time.time()
cutoff = now - _STASH_RL_WINDOW_SEC
with _stash_rl_lock:
if len(_stash_rl_state) > 256:
stale = [ip for ip, hits in _stash_rl_state.items() if not hits or hits[-1] < cutoff]
for ip in stale:
_stash_rl_state.pop(ip, None)
hits = _stash_rl_state.setdefault(remote_ip, [])
i = 0
while i < len(hits) and hits[i] < cutoff:
i += 1
if i:
del hits[:i]
if len(hits) >= _STASH_RL_MAX_PER_WINDOW:
return False
hits.append(now)
return True
def _stash_prune_expired_locked():
now = time.time()
expired = [k for k, (ts, _v) in _setup_secret_stash.items() if now - ts > _SETUP_STASH_TTL_SEC]
for k in expired:
_setup_secret_stash.pop(k, None)
def _stash_set(client_id, payload):
with _setup_secret_lock:
_stash_prune_expired_locked()
if len(_setup_secret_stash) >= _SETUP_STASH_MAX_ENTRIES and client_id not in _setup_secret_stash:
oldest_key = min(_setup_secret_stash, key=lambda k: _setup_secret_stash[k][0])
_setup_secret_stash.pop(oldest_key, None)
_setup_secret_stash[client_id] = (time.time(), payload)
def _stash_consume(client_id):
with _setup_secret_lock:
_stash_prune_expired_locked()
entry = _setup_secret_stash.pop(client_id, None)
if entry is None:
return None
_ts, payload = entry
return payload
def _register_stash_route_once():
"""Register the /drivesend/setup/stash POST handler on PromptServer.
Idempotent. If PromptServer is not ready yet, registration is silently
skipped and the Setup Node will surface a clear error when the user
tries to save credentials.
"""
if getattr(_register_stash_route_once, "_registered", False):
return
try:
from server import PromptServer
from aiohttp import web
except Exception:
return
instance = getattr(PromptServer, "instance", None)
if instance is None or not hasattr(instance, "routes"):
return
@instance.routes.post("/drivesend/setup/stash")
async def _stash_handler(request):
# Same-origin enforcement. Without this, a malicious page in
# another tab could attempt a CSRF POST against
# http://127.0.0.1:8188.
origin = request.headers.get("Origin", "")
if origin:
try:
from urllib.parse import urlparse
origin_host = urlparse(origin).netloc
except Exception:
origin_host = ""
if not origin_host or origin_host != request.host:
return web.json_response({"error": "cross-origin denied"}, status=403)
if request.content_type and request.content_type != "application/json":
return web.json_response(
{"error": "Content-Type must be application/json"},
status=415,
)
remote_ip = request.headers.get("X-Forwarded-For", request.remote or "").split(",")[0].strip()
if not _stash_rl_check(remote_ip):
return web.json_response({"error": "rate limit"}, status=429)
if request.content_length is not None and request.content_length > 32 * 1024:
return web.json_response({"error": "payload too large"}, status=413)
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid json"}, status=400)
client_id_field = data.get("client_id") if isinstance(data, dict) else None
if not client_id_field or not isinstance(client_id_field, str) or not _CLIENT_ID_PATTERN.match(client_id_field):
return web.json_response({"error": "client_id required"}, status=400)
# Authenticate by liveness: only accept stash writes for a
# client_id that currently has an active WebSocket connection
# to this PromptServer instance.
try:
sockets = getattr(instance, "sockets", None)
if not sockets or client_id_field not in sockets:
return web.json_response({"error": "client_id has no active WebSocket"}, status=403)
except Exception:
return web.json_response({"error": "internal"}, status=500)
payload = {
"google_client_id": str(data.get("google_client_id") or ""),
"google_client_secret": str(data.get("google_client_secret") or ""),
"auth_code": str(data.get("auth_code") or ""),
"service_account_json": str(data.get("service_account_json") or ""),
}
if not any(payload.values()):
return web.json_response({"ok": True, "stashed": False})
_stash_set(client_id_field, payload)
return web.json_response({"ok": True, "stashed": True})
_register_stash_route_once._registered = True
print("[DriveSend Setup] Registered /drivesend/setup/stash route (out-of-band secret transport)")
_register_stash_route_once()
# ---------------------------------------------------------------------------
# Opt-in gate
# ---------------------------------------------------------------------------
# Workflow inputs to the Setup Node can come from any caller that can
# submit a workflow to ComfyUI. On a network-reachable host that means a
# remote attacker can wipe credentials or overwrite the stored Google
# Drive app to redirect uploads to their own account. We require the
# operator to opt in by setting this env var on the host before the
# Setup Node performs any state-changing action.
_SETUP_OPT_IN_VAR = "COMFYUI_DRIVESEND_ALLOW_SETUP"
_SETUP_OPT_IN_HELP = (
f"Setup is disabled. To allow this node to write or clear credentials, "
f"set {_SETUP_OPT_IN_VAR}=1 in the host environment before starting "
f"ComfyUI, or provision GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / "
f"GOOGLE_REFRESH_TOKEN directly via environment variables (e.g. RunPod "
f"secrets). This guard prevents remote workflow submitters from "
f"hijacking or wiping your Google Drive connection."
)
def _setup_opt_in_enabled():
return os.getenv(_SETUP_OPT_IN_VAR, "").strip().lower() in ("1", "true", "yes")
# ---------------------------------------------------------------------------
# Redaction helpers for the post-setup banner
# ---------------------------------------------------------------------------
_SECRET_FIELDS = {
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_REFRESH_TOKEN",
"GOOGLE_SERVICE_ACCOUNT_JSON",
"COMFYUI_ENCRYPTION_KEY",
}
def _redact(value, visible_tail=4):
if not value:
return ""
if len(value) <= visible_tail:
return "*" * len(value)
return "*" * (len(value) - visible_tail) + value[-visible_tail:]
def _redact_kv_line(line):
name, sep, value = line.partition("=")
if sep and name in _SECRET_FIELDS:
return f"{name}={_redact(value)}"
return line
# ---------------------------------------------------------------------------
# The Setup Node
# ---------------------------------------------------------------------------
class DriveSendSetupNode:
"""Setup node for configuring Google Drive authentication."""
CATEGORY = "DriveSend"
FUNCTION = "setup"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("status",)
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
# Architecture: NO credential-bearing inputs. google_client_id,
# google_client_secret, auth_code, and the service-account JSON
# body all live exclusively in the per-session stash populated
# by the browser modal.
return {
"required": {
"auth_method": (["oauth", "service_account"], {"default": "oauth"}),
"folder_id": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Google Drive folder ID from URL (the part after /folders/)"
}),
"storage_method": (["display_only", "env_file"], {
"label": "Credential Storage Method",
"default": "display_only"
}),
"encryption_key_method": (["off", "display_only", "save_to_env"], {
"label": "Encryption Key Method",
"default": "off"
}),
},
"optional": {
"reconnect": ("BOOLEAN", {
"label": "Reset stored credentials",
"default": False
}),
}
}
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
# Force re-execution on every Queue. setup() is intentionally
# side-effecting, and the input dict here does not reflect the
# actual work (the real driver is the out-of-band stash entry).
return float("NaN")
def setup(self, auth_method, folder_id, storage_method, encryption_key_method, reconnect=False):
try:
print(f"[DriveSend Setup] Called: auth_method={auth_method}, "
f"reconnect={reconnect}, storage_method={storage_method}, "
f"encryption_key_method={encryption_key_method}")
# Pull stash for this client (browser-only entry).
google_client_id = None
google_client_secret = None
auth_code = None
service_account_json = None
sid = None
try:
from server import PromptServer
sid = getattr(PromptServer.instance, "client_id", None)
if sid:
stashed = _stash_consume(sid)
if stashed:
google_client_id = stashed.get("google_client_id") or None
google_client_secret = stashed.get("google_client_secret") or None
auth_code = stashed.get("auth_code") or None
service_account_json = stashed.get("service_account_json") or None
print("[DriveSend Setup] Loaded secrets from out-of-band stash")
except Exception as e:
print(f"[DriveSend Setup] Warning: could not consume secret stash: {e}")
# ----- Reconnect (clear all stored credentials) -----
if reconnect:
if not _setup_opt_in_enabled():
message = "Reconnect refused: " + _SETUP_OPT_IN_HELP
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
print("[DriveSend Setup] Reconnect requested - clearing all credentials")
env_path = NODE_DIR / ".env"
token_path = NODE_DIR / "token.json"
if env_path.exists():
print(f"[DriveSend Setup] Removing .env file: {env_path}")
try:
os.remove(env_path)
except OSError as e:
print(f"[DriveSend Setup] Warning: could not remove .env: {e}")
if token_path.exists():
print(f"[DriveSend Setup] Removing token.json: {token_path}")
try:
os.remove(token_path)
except OSError as e:
print(f"[DriveSend Setup] Warning: could not remove token.json: {e}")
# Clear in-process os.environ so subsequent runs do not
# short-circuit on the previously-loaded values.
for key in (
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN",
"GOOGLE_SERVICE_ACCOUNT_JSON",
):
if key in os.environ:
os.environ.pop(key, None)
print(f"[DriveSend Setup] Cleared {key} from process environment")
# WebSocket notification, sid-targeted only (refuse to
# broadcast on sid=None).
try:
from server import PromptServer
notify_sid = getattr(PromptServer.instance, "client_id", None)
if not notify_sid:
print(
"[DriveSend Setup] Skipping reconnect-complete "
"broadcast: prompt has no client_id (CLI "
"submission?). Submit from the ComfyUI web UI "
"to receive the refresh notification."
)
else:
PromptServer.instance.send_sync(
"drivesend_reconnect_complete",
{
"type": "drivesend_reconnect_complete",
"success": True,
"client_id": notify_sid,
"message": "Credentials cleared. ComfyUI will refresh to show auth fields",
},
notify_sid,
)
print("[DriveSend Setup] Sent WebSocket notification for reconnect completion")
except Exception as e:
print(f"[DriveSend Setup] Warning: Could not send WebSocket notification: {e}")
message = "Google Drive credentials cleared."
return {"ui": {"text": [message]}, "result": (message,)}
# ----- folder_id is required -----
if not folder_id or not str(folder_id).strip():
message = "Error: folder_id is required. Get it from your Google Drive folder URL (the part after /folders/)."
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
from .safe_paths import validate_drive_folder_id
try:
folder_id = validate_drive_folder_id(folder_id)
except ValueError as e:
message = f"Error: {e}"
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
# ----- env_vars short-circuit (already configured) -----
#
# If credentials are already provisioned in the host environment
# (RunPod secrets, Docker env, etc.), there's nothing to do.
if auth_method == "oauth":
env_vars_set = all([
os.getenv("GOOGLE_CLIENT_ID"),
os.getenv("GOOGLE_CLIENT_SECRET"),
os.getenv("GOOGLE_REFRESH_TOKEN"),
])
if env_vars_set and not auth_code and not google_client_id:
# Persist the folder_id only (non-sensitive). Use the
# secure write helper.
_persist_env_writes([("GOOGLE_DRIVE_FOLDER_ID", folder_id)])
message = (
"Google Drive credentials already configured via host environment. "
"Folder ID saved. Ready to upload."
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
elif auth_method == "service_account":
if os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON") and not service_account_json:
_persist_env_writes([("GOOGLE_DRIVE_FOLDER_ID", folder_id)])
message = (
"Service account already configured via host environment. "
"Folder ID saved. Ready to upload."
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
# ----- Anything below this line writes credential state -----
if not _setup_opt_in_enabled():
message = _SETUP_OPT_IN_HELP
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
# ----- Generate or reuse encryption key -----
encryption_key = None
if encryption_key_method != "off":
existing_key = (
os.environ.get("COMFYUI_ENCRYPTION_KEY")
or os.environ.get("comfyui_encryption_key")
or os.environ.get("DROPSEND_ENCRYPTION_KEY")
or os.environ.get("DRIVESEND_ENCRYPTION_KEY")
or os.environ.get("RUNPOD_SECRET_COMFYUI_ENCRYPTION_KEY")
)
if existing_key:
encryption_key = existing_key
print("[DriveSend Setup] Using existing encryption key from environment")
else:
encryption_key = Fernet.generate_key().decode("utf-8")
print("[DriveSend Setup] Generated new encryption key")
# ----- Service Account flow -----
if auth_method == "service_account":
return self._handle_service_account(
folder_id, storage_method, encryption_key_method,
encryption_key, service_account_json,
)
# ----- OAuth flow -----
return self._handle_oauth(
folder_id, storage_method, encryption_key_method,
encryption_key, google_client_id, google_client_secret,
auth_code, sid,
)
except Exception as e:
logger.exception("[DriveSend Setup] Unexpected error")
message = f"Setup failed with an unexpected error: {e}"
return {"ui": {"text": [message]}, "result": (message,)}
# -----------------------------------------------------------------
# OAuth path
# -----------------------------------------------------------------
def _handle_oauth(self, folder_id, storage_method, encryption_key_method,
encryption_key, google_client_id, google_client_secret,
auth_code, sid):
if not google_client_id or not google_client_secret:
# No client_id / client_secret in stash. Tell the user to
# click "Set credentials..." on the node.
message = (
"OAuth requires google_client_id and google_client_secret.\n\n"
"Click 'Set credentials...' on this node, paste your OAuth "
"Client ID and Client Secret from Google Cloud Console, leave "
"the auth code blank, click Save, then click Queue.\n\n"
"Get a Client ID and Client Secret here:\n"
" Google Cloud Console -> APIs & Services -> Credentials\n"
" Create OAuth 2.0 Client ID (Desktop app)"
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
from .gdrive_auth_manager import get_oauth_credentials_for_setup
if not auth_code:
# First run: generate the auth URL and ask the browser to
# auto-open the entry modal so the user can paste the auth
# code on the next round.
result = get_oauth_credentials_for_setup(google_client_id, google_client_secret)
if "auth_url" not in result:
err = result.get("error", "Unknown error")
message = f"Error generating auth URL: {err}"
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
auth_url = result["auth_url"]
# The auth URL contains your OAuth client_id, which Google
# treats as a public identifier (it's in every OAuth URL
# ever issued by your app). Safe to print.
print()
print("=" * 80)
print("DRIVESEND - GOOGLE DRIVE AUTHORIZATION REQUIRED")
print("=" * 80)
print("Open this URL in your browser to authorize:")
print()
print(f" {auth_url}")
print()
print("After authorizing, Google will display an authorization code.")
print("Paste it into the credentials modal that auto-opens in your")
print("browser, then click Save and re-queue this workflow.")
print("=" * 80)
print()
# Auto-trigger the entry modal in the originating browser.
try:
from server import PromptServer
if sid:
PromptServer.instance.send_sync(
"drivesend_credentials_needed",
{"client_id": sid, "stage": "auth_code"},
sid,
)
print("[DriveSend Setup] Sent credentials-needed event to browser")
except Exception as e:
print(f"[DriveSend Setup] Warning: could not send credentials-needed event: {e}")
message = (
"Google OAuth Ready!\n\n"
"Click the link below (also printed in the ComfyUI terminal):\n\n"
f"{auth_url}\n\n"
"Authorize, copy the code Google shows you, then paste your "
"Client ID, Client Secret, and Auth Code in the modal that "
"auto-opens. Click Save, then click Queue."
)
return {"ui": {"text": [message]}, "result": (message,)}
# Second run: exchange the auth code.
result = get_oauth_credentials_for_setup(
google_client_id, google_client_secret, auth_code
)
if "error" in result:
message = (
f"OAuth exchange failed: {result['error']}\n\n"
"The auth code may have expired or already been used.\n"
"Click 'Set credentials...' again to enter a fresh one, "
"or rerun the node with no auth code to regenerate the URL."
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
creds = result.get("credentials", {})
refresh_token = creds.get("refresh_token")
if not refresh_token:
message = (
"OAuth exchange returned no refresh token. This usually means "
"the user did not see the consent screen (already authorized). "
"Revoke access at https://myaccount.google.com/permissions and "
"rerun setup."
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
return self._deliver_credentials(
auth_method="oauth",
folder_id=folder_id,
storage_method=storage_method,
encryption_key_method=encryption_key_method,
encryption_key=encryption_key,
sid=sid,
credentials_payload=[
("GOOGLE_CLIENT_ID", google_client_id),
("GOOGLE_CLIENT_SECRET", google_client_secret),
("GOOGLE_REFRESH_TOKEN", refresh_token),
],
)
# -----------------------------------------------------------------
# Service Account path
# -----------------------------------------------------------------
def _handle_service_account(self, folder_id, storage_method, encryption_key_method,
encryption_key, service_account_json):
# Three sources of service account JSON, checked in order:
# 1. Stashed by the browser modal (paste into the dedicated field)
# 2. service_account.json in the plugin directory (legacy)
# 3. nothing -> tell the user how to provide it
sa_data = None
sa_source = None
if service_account_json:
try:
sa_data = json.loads(service_account_json)
sa_source = "modal"
except json.JSONDecodeError as e:
message = f"Service account JSON could not be parsed: {e}"
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
if sa_data is None:
sa_file = NODE_DIR / "service_account.json"
if sa_file.exists():
try:
with open(sa_file, "r") as f:
sa_data = json.load(f)
sa_source = "file"
except (OSError, json.JSONDecodeError) as e:
message = f"Could not read service_account.json: {e}"
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
if sa_data is None:
message = (
"No service account configured. Two options:\n\n"
"(a) Click 'Set credentials...' on this node and paste the "
"JSON contents of your service-account key file into the "
"Service Account JSON field.\n\n"
"(b) Place the file at "
"ComfyUI/custom_nodes/ComfyUI_DriveSendNode/service_account.json\n\n"
"Note: service accounts only work with Google Workspace (paid). "
"For personal Gmail accounts, use OAuth instead."
)
print(f"[DriveSend Setup] {message}")
return {"ui": {"text": [message]}, "result": (message,)}
sa_email = sa_data.get("client_email", "unknown")
sa_json_b64 = base64.b64encode(json.dumps(sa_data).encode()).decode()
print(f"[DriveSend Setup] Service account loaded ({sa_source}): {sa_email}")
return self._deliver_credentials(
auth_method="service_account",
folder_id=folder_id,
storage_method=storage_method,
encryption_key_method=encryption_key_method,
encryption_key=encryption_key,
sid=None,
credentials_payload=[("GOOGLE_SERVICE_ACCOUNT_JSON", sa_json_b64)],
extra_message=(
f"Service account email: {sa_email}\n"
f"Make sure your Drive folder is shared with that email "
f"(otherwise uploads will fail with a 'file not found' error)."
),
)
# -----------------------------------------------------------------
# Credential delivery (env_file or display_only)
# -----------------------------------------------------------------
def _deliver_credentials(self, auth_method, folder_id, storage_method,
encryption_key_method, encryption_key, sid,
credentials_payload, extra_message=None):
# folder_id is non-sensitive and always persisted to .env so
# the AutoUploader can find it.
env_writes = [("GOOGLE_DRIVE_FOLDER_ID", folder_id)]
ws_payload = {}
if storage_method == "env_file":
env_writes.extend(credentials_payload)
else:
for name, value in credentials_payload:
ws_payload[name] = value
if encryption_key_method == "save_to_env" and encryption_key:
env_writes.append(("COMFYUI_ENCRYPTION_KEY", encryption_key))
elif encryption_key_method == "display_only" and encryption_key:
ws_payload["COMFYUI_ENCRYPTION_KEY"] = encryption_key
# Persist .env. Race-free 0o600 open. Merges with existing
# values so re-running setup does not wipe non-setup keys
# (uploader settings, etc.).
env_path = _persist_env_writes(env_writes, scrub_setup_keys=True)
# OAuth: also write token.json for local CLI use, with secure
# permissions. Only if storage_method is env_file (otherwise
# display_only's whole point is "no secrets on disk").
if auth_method == "oauth" and storage_method == "env_file":
creds_dict = {name: value for name, value in credentials_payload}
if all(k in creds_dict for k in ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN")):
_write_token_json(
client_id=creds_dict["GOOGLE_CLIENT_ID"],
client_secret=creds_dict["GOOGLE_CLIENT_SECRET"],
refresh_token=creds_dict["GOOGLE_REFRESH_TOKEN"],
)
# WebSocket delivery for display_only (sid-targeted, never broadcast).
ws_delivered = False
ws_delivery_error = None
if ws_payload:
try:
from server import PromptServer
deliver_sid = sid or getattr(PromptServer.instance, "client_id", None)
if not deliver_sid:
ws_delivery_error = (
"prompt has no client_id (typical for CLI/curl/SDK "
"submissions). display_only delivery requires "
"submitting from the ComfyUI web browser so a "
"client_id is attached to the prompt."
)
print(
"[DriveSend Setup] Refusing to deliver credentials: "
"prompt has no client_id. Sending to sid=None would "
"broadcast to every connected browser. Submit from "
"the ComfyUI web UI instead."
)
else:
PromptServer.instance.send_sync(
"drivesend_credentials_ready",
{"credentials": dict(ws_payload), "client_id": deliver_sid},
deliver_sid,
)
ws_delivered = True
except Exception as e:
ws_delivery_error = str(e)
print(f"[DriveSend Setup] WebSocket delivery failed: {e}")
# Build status message. Contains NO credential values.
message_parts = ["Google Drive connected."]
if extra_message:
message_parts.append(extra_message)
if ws_payload and ws_delivered:
message_parts.append(
"Next steps:\n"
" 1. Copy each value from the DriveSend Credentials panel "
"to a safe place (password manager, secure note).\n"
" 2. Add them to your platform's secrets configuration "
"(RunPod Secrets, Docker env, systemd EnvironmentFile).\n"
" 3. Restart your pod / container so the new secrets are "
"visible to the ComfyUI process.\n"
" 4. On the restarted pod, remove this Setup node from "
"your workflow and run the 'DriveSend - AutoUploader' node."
)
elif ws_payload and not ws_delivered:
message_parts.append(
f"Browser delivery refused: {ws_delivery_error}\n\n"
f"What to do:\n"
f" - Cloud / RunPod / hosted ComfyUI: Submit this workflow "
f"from your web browser (not via curl / API / SDK). The "
f"browser's WebSocket connection attaches a client_id "
f"automatically. display_only never writes credentials to "
f"the pod's filesystem.\n"
f" - Local install only: switch storage_method to "
f"'env_file' and rerun if you don't mind credentials in a "
f"0600 .env file on disk."
)
elif env_writes:
message_parts.append(
"You can remove this Setup node from your workflow and run "
"the 'DriveSend - AutoUploader' node."
)
message = "\n\n".join(message_parts)
# Console banner. Confirmation only, values redacted.
print("\n" + "=" * 80)
print("DRIVESEND SETUP COMPLETE (values redacted in console)")
if env_writes:
print(f"Wrote to file: {env_path}")
for name, value in env_writes:
print(" " + _redact_kv_line(f"{name}={value}"))
if ws_payload:
label = "Sent to browser (display_only):"
print(label)
for name in ws_payload:
# Field names only for display_only. The values rode the
# WS payload, never stdout.
print(f" {name}=<delivered to browser>")
print("=" * 80 + "\n")
return {"ui": {"text": [message]}, "result": (message,)}
# ---------------------------------------------------------------------------
# Persistence helpers (race-free, 0o600)
# ---------------------------------------------------------------------------
# Keys the Setup Node "owns". On a setup re-run that does NOT write a
# given key, it gets scrubbed from .env so old values don't linger when
# switching env_file -> display_only. GOOGLE_DRIVE_FOLDER_ID is owned by
# the AutoUploader, so it's not in this set.
_SETUP_KEYS = {
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_REFRESH_TOKEN",
"GOOGLE_SERVICE_ACCOUNT_JSON",
"COMFYUI_ENCRYPTION_KEY",
}
def _persist_env_writes(env_writes, scrub_setup_keys=False):
"""Write (key, value) pairs to .env with 0o600 permissions.
Merges with existing .env contents so non-setup keys (uploader
settings, etc.) survive re-runs unchanged. If scrub_setup_keys is
True, setup-owned keys NOT being rewritten by this run are dropped
from disk (covers the env_file -> display_only switch).
"""
env_path = NODE_DIR / ".env"
existing = {}
if env_path.exists():
existing = dict(dotenv_values(env_path))
if scrub_setup_keys:
existing = {k: v for k, v in existing.items() if k not in _SETUP_KEYS}
merged = dict(existing)
for name, value in env_writes:
merged[name] = value
if merged:
fd = os.open(str(env_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
for k, v in merged.items():
f.write(f"{k}={v}\n")
# Load freshly written values into os.environ so the
# AutoUploader can use them in the same session.
load_dotenv(env_path, override=True)
elif env_path.exists():
os.remove(env_path)
return env_path
def _write_token_json(client_id, client_secret, refresh_token):
"""Write token.json with 0o600 permissions for local CLI use."""
token_path = NODE_DIR / "token.json"
token_data = {
"token": None,
"refresh_token": refresh_token,
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": client_id,
"client_secret": client_secret,
"scopes": ["https://www.googleapis.com/auth/drive.file"],
}
fd = os.open(str(token_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(token_data, f, indent=2)
# ---------------------------------------------------------------------------
# Node registration
# ---------------------------------------------------------------------------
NODE_CLASS_MAPPINGS = {
"DriveSendSetup": DriveSendSetupNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DriveSendSetup": "🚙⚙️ DriveSend - Setup Node"
}