diff --git a/.gitignore b/.gitignore index 9110d1f..ed05801 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ env/ .env .env.* +# Dropbox auth manager credential cache (refresh token + app secret) +.dropbox_credentials.json + # MacOS system files .DS_Store @@ -36,3 +39,39 @@ logs/ # old backed up project archived files to ignore _old/ + +# Editor/IDE backups (may capture secrets from edited files like .env) +*.bak +*.backup +*~ +*.swp +*.swo +.idea/ + +# Encrypted artifacts from local test runs +*.enc + +# Stray credential material +*.key +*.pem +*.p12 +*.pfx + +# Claude Code session data +.claude/ + +# Python test artifacts +.pytest_cache/ +.coverage +htmlcov/ + +# Python build artifacts +*.egg-info/ +build/ +dist/ +pip-log.txt +pip-wheel-metadata/ + +# Windows OS files +Thumbs.db +Desktop.ini diff --git a/Images/DropSend_Node_Display.jpg b/Images/DropSend_Node_Display.jpg deleted file mode 100644 index e49e625..0000000 Binary files a/Images/DropSend_Node_Display.jpg and /dev/null differ diff --git a/Images/DropSend_Node_Display.png b/Images/DropSend_Node_Display.png new file mode 100644 index 0000000..26f20ad Binary files /dev/null and b/Images/DropSend_Node_Display.png differ diff --git a/README.md b/README.md index d80759e..095ba54 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ -# ComfyUI DropSend Node (ComfyUI >> DropBox) +# ComfyUI DropSend Node (ComfyUI to Dropbox) -Automatically upload your ComfyUI output files to Dropbox with optional encryption. Set it and forget it! +Automatically upload your ComfyUI output files to Dropbox with optional encryption. Set it and forget it. > **Prefer Google Drive?** Check out [DriveSend Node](https://github.com/machinepainting/ComfyUI_DriveSendNode) -![DropSend Node Overview](Images/DropSend_Node_Display.jpg) +![DropSend Node Overview](Images/DropSend_Node_Display.png) --- -## πŸ”„ How It Works +## How It Works ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” @@ -39,31 +39,21 @@ Automatically upload your ComfyUI output files to Dropbox with optional encrypti β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -**Encryption is completely optional.** Leave `enable_encryption` off and files upload directly as-is. +Encryption is optional. Leave `enable_encryption` off and files upload as-is. --- -## πŸ“€ Features - -### DropSend AutoUploader Node -- Real-time folder monitoring with optional subfolder support -- Supports: `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.mp4`, `.avi`, `.mov` -- Optional AES-128 encryption before upload -- SHA256 checksum verification -- Queue system for reliable uploads +## Two Nodes ### DropSend Setup Node -- Generates Dropbox refresh token from App Key/Secret -- Outputs credentials for RunPod environment variables -- Optional encryption key generation +Runs once. Exchanges your Dropbox app credentials for a refresh token, then either writes them to `.env` (local installs) or shows them in a one-shot browser panel (cloud installs) so you can copy them into your platform's secrets manager. -### Decryption Scripts (Local Use) -- Cross-platform scripts for macOS, Windows, and Linux -- Decrypt `.enc` files after downloading from Dropbox +### DropSend AutoUploader Node +Runs every workflow. Watches your ComfyUI output folder, optionally encrypts each new file, and uploads it to Dropbox. Supports `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.mp4`, `.avi`, `.mov`. Includes SHA256 verification, queue-based retries, and optional subfolder monitoring. --- -## πŸ’Ύ Installation +## Installation ```bash cd ComfyUI/custom_nodes/ @@ -75,238 +65,404 @@ Restart ComfyUI after installation. --- -## πŸ”§ Dropbox App Setup +## Step 1: Create a Dropbox App -1. Login to [Dropbox](https://www.dropbox.com/) -2. Go to [Dropbox Developers](https://www.dropbox.com/developers/) β†’ Click **Create Apps** -3. Select **Scoped Access** β†’ **App Folder** (recommended) -4. Name your app β†’ Click **Create app** -5. Go to **Permissions** tab and enable: +1. Log in to [Dropbox](https://www.dropbox.com/) +2. Open [Dropbox Developers](https://www.dropbox.com/developers/) and click **Create Apps** +3. Choose **Scoped Access** then **App Folder** (recommended) +4. Name your app and click **Create app** +5. On the **Permissions** tab, enable: - `account_info.write`, `account_info.read` - `files.metadata.write`, `files.metadata.read` - `files.content.write`, `files.content.read` 6. Click **Submit** to save permissions -7. Go to **Settings** tab β†’ Note your **App Key** and **App Secret** +7. On the **Settings** tab, copy your **App Key** and **App Secret** -> **Note:** If you can't select App Folder, create an `/Apps/` folder in your Dropbox root first. +> If "App Folder" is not selectable, create an `/Apps/` folder in your Dropbox root first. --- -## πŸƒβ€β™‚οΈ DropSend Setup (RunPod & Cloud Users) +## Step 2: Run the Setup Node -### Step 1: Run Setup Node +The Setup Node is gated behind an environment variable. This prevents a remote workflow from hijacking or wiping your credentials. Pick the section that matches where you run ComfyUI. -1. Add **DropSend Setup Node** to your workflow -2. Configure: - - `app_key`: Paste your App Key - - `app_secret`: Paste your App Secret - - `auth_code`: Leave blank (for now) - - `dropbox_dest_folder`: `/Apps/ComfyUI_Output_Files` (or your choice) - - `storage_method`: **display_only** - - `encryption_key_method`: **Display Only** (or off if not using encryption) +### A) Cloud (RunPod and similar) -3. Run the node β†’ Two Dropbox popups appear -4. Accept permissions β†’ Copy the authorization code -5. Paste code into `auth_code` field -6. Run the node again - -### Step 2: Copy Credentials from Console - -The console displays: +**1. Set the gate.** Add this to your RunPod template's **Environment Variables**: ``` -DROPBOX_APP_KEY: XXXXXXXXXX -DROPBOX_APP_SECRET: XXXXXXXXXX -DROPBOX_REFRESH_TOKEN: XXXXXXXXXX -COMFYUI_ENCRYPTION_KEY=XXXXXXXXXX (if encryption enabled) +COMFYUI_DROPSEND_ALLOW_SETUP=1 ``` -**Copy only the value after the `:` or `=` sign** (not the variable name). +Start (or restart) the pod. To verify, open a pod shell and run: -### Step 3: Create RunPod Secrets +```bash +echo $COMFYUI_DROPSEND_ALLOW_SETUP # should print: 1 +``` -1. Go to [RunPod.io](https://www.runpod.io) β†’ Click **Secrets** in sidebar -2. Create these secrets (Make sure the name of the secret is EXACTLY as shown below): +**2. Add the Setup Node to your workflow.** Configure: -| Secret Name | Secret Value | -|-------------|--------------| -| `DROPBOX_APP_KEY` | Your app key | -| `DROPBOX_APP_SECRET` | Your app secret | -| `DROPBOX_REFRESH_TOKEN` | Your refresh token | -| `COMFYUI_ENCRYPTION_KEY` | Your encryption key *(optional - only if using encryption)* | +| Field | Value | +|---|---| +| `storage_method` | `display_only` (values shown in browser, never saved on the pod) | +| `encryption_key_method` | `Display Only` (or `off` if you do not want encryption) | +| `reconnect` | leave off | -### Step 4: Create a Custom Template (Recommended) +**3. Click "Set credentials..."** on the node. A modal opens with three password fields: -Creating a template saves your environment variables so you don't have to add them every time. +| Field | What to paste | +|---|---| +| App Key | Your Dropbox App Key (from Step 1) | +| App Secret | Your Dropbox App Secret (from Step 1) | +| Auth Code | Leave blank for now | -1. Go to [RunPod.io](https://www.runpod.io) β†’ Click **My Templates** in sidebar -2. Click **+ New Template** -3. Configure your template settings (container image, GPU, etc.) -4. Click **Environment Variables** dropdown -5. Click **+ Add Environment Variable** for each: +Click **Save**. The modal closes. -| Key | Value (click πŸ—οΈ and select secret) | -|-----|-------------------------------------| +**4. Click Queue (Run).** The node prints an OAuth URL. The URL is delivered three ways, use whichever works: + +- A browser popup opens automatically (preferred) +- The ComfyUI terminal prints a `DROPSEND DROPBOX AUTHORIZATION REQUIRED` banner with the URL (Cmd or Ctrl click to open) +- Wire a `Show Text` node to the Setup Node's `STRING` output + +**5. Authorize at Dropbox.** Open the URL, click Allow, and copy the authorization code Dropbox displays. + +**6. Paste the auth code.** A second modal auto-opens for this. Paste your App Key, App Secret, and the new Auth Code. Click **Save**. + +**7. Click Queue again.** A **DropSend Credentials** panel appears in your browser with four values: + +| Value | Description | +|---|---| +| `DROPBOX_APP_KEY` | From your Dropbox app | +| `DROPBOX_APP_SECRET` | From your Dropbox app | +| `DROPBOX_REFRESH_TOKEN` | Long-lived token, just generated | +| `COMFYUI_ENCRYPTION_KEY` | Only if you chose to generate one | + +Each value has a **Copy** button. There is also a **Copy all as NAME=value** button. + +The panel is browser-only. Closing it discards the data and nothing is saved on the pod. If you close it before copying, just rerun the Setup Node to regenerate the values. + +**8. Save the values to RunPod Secrets.** In RunPod, click **Secrets** in the sidebar and create: + +| Secret Name (must match exactly) | Value | +|---|---| +| `DROPBOX_APP_KEY` | from the panel | +| `DROPBOX_APP_SECRET` | from the panel | +| `DROPBOX_REFRESH_TOKEN` | from the panel | +| `COMFYUI_ENCRYPTION_KEY` | from the panel (only if using encryption) | + +**9. Add the secrets to your pod template.** Click **My Templates**, edit your template, and under **Environment Variables** add: + +| Key | Value | +|---|---| | `DROPBOX_APP_KEY` | `{{ RUNPOD_SECRET_DROPBOX_APP_KEY }}` | | `DROPBOX_APP_SECRET` | `{{ RUNPOD_SECRET_DROPBOX_APP_SECRET }}` | | `DROPBOX_REFRESH_TOKEN` | `{{ RUNPOD_SECRET_DROPBOX_REFRESH_TOKEN }}` | -| `COMFYUI_ENCRYPTION_KEY` | `{{ RUNPOD_SECRET_COMFYUI_ENCRYPTION_KEY }}` *(optional)* | +| `COMFYUI_ENCRYPTION_KEY` | `{{ RUNPOD_SECRET_COMFYUI_ENCRYPTION_KEY }}` (optional) | -6. Click **Save Template** -7. When deploying a new pod, click **Change Template** β†’ select your custom template. Done! +Save the template. The next time you deploy a pod from this template, ComfyUI will see the credentials automatically. -> ⚠️ **Why this matters:** If you skip creating a custom template, you'll have to manually add all 4 environment variables every single time you start a new pod. Save yourself the headache β€” make the template once and reuse it. +> You can now remove `COMFYUI_DROPSEND_ALLOW_SETUP=1` from the template unless you plan to re-run the Setup Node from this pod. -> **πŸ’‘ New to templates?** Take a template you already use, copy its container/docker settings into a new custom template, add the environment variables above, and save. That's it. - -> **Already have a pod running?** Terminate it β†’ deploy a new pod using your custom template. This is the cleanest way to pick up the new environment variables. - -### Step 5: Test Upload - -1. Add **DropSend AutoUploader Node** to your workflow -2. Set `run_process` to **True** -3. Run a workflow that generates an image -4. Check your Dropbox folder! +**10. Deploy a fresh pod from your template.** Skip to **Step 3**. --- -## πŸ’» DropSend Setup (Local Users) +### B) Local install -1. Add **DropSend Setup Node** to your workflow +**1. Set the gate in the same shell that will launch ComfyUI:** + +```bash +# macOS / Linux +export COMFYUI_DROPSEND_ALLOW_SETUP=1 + +# Windows (Command Prompt) +set COMFYUI_DROPSEND_ALLOW_SETUP=1 + +# Windows (PowerShell) +$env:COMFYUI_DROPSEND_ALLOW_SETUP = "1" +``` + +Verify: + +```bash +# macOS / Linux +echo $COMFYUI_DROPSEND_ALLOW_SETUP + +# Windows (Command Prompt) +echo %COMFYUI_DROPSEND_ALLOW_SETUP% + +# Windows (PowerShell) +echo $env:COMFYUI_DROPSEND_ALLOW_SETUP +``` + +Each command should print `1`. Now launch ComfyUI from that same shell. + +**2. Add the Setup Node to your workflow.** Configure: + +| Field | Value | +|---|---| +| `storage_method` | `env_file` (values written to `.env`, mode `0600`, in the plugin directory) | +| `encryption_key_method` | `save to .env` (or `off`) | +| `reconnect` | leave off | + +**3. Click "Set credentials..."** on the node. In the modal: + +| Field | What to paste | +|---|---| +| App Key | Your Dropbox App Key | +| App Secret | Your Dropbox App Secret | +| Auth Code | Leave blank | + +Click **Save**. + +**4. Click Queue.** The node prints an OAuth URL. Open it, authorize at Dropbox, and copy the auth code. + +**5. Paste the auth code.** A second modal auto-opens. Paste App Key, App Secret, and Auth Code. Click **Save**. + +**6. Click Queue again.** Credentials are written to `.env` in the plugin directory and loaded into the running ComfyUI process. No restart needed. + +> Once setup is complete, you can drop `COMFYUI_DROPSEND_ALLOW_SETUP` from your environment. The AutoUploader does not need it. + +--- + +## Step 3: Use the AutoUploader + +1. Add the **DropSend AutoUploader Node** to your workflow. 2. Configure: - - `app_key`: Paste your App Key - - `app_secret`: Paste your App Secret - - `auth_code`: Leave blank (for now) - - `dropbox_dest_folder`: `/Apps/ComfyUI_Output_Files` - - `storage_method`: **env_file** - - `encryption_key_method`: **save to .env** (or off) -3. Run the node β†’ Accept Dropbox permissions β†’ Copy auth code -4. Paste code into `auth_code` field β†’ Run again -5. Restart ComfyUI to load the `.env` file -6. Add **DropSend AutoUploader Node** and run! +| Field | What it does | +|---|---| +| `watch_folder` | Folder to monitor for new files (defaults to ComfyUI output, see clamp note in Security section) | +| `dropbox_dest_folder` | Destination folder in Dropbox (must start with `/Apps/...`) | +| `folder_format` | Append today's date to the destination, or use the literal name | +| `enable_encryption` | If true, encrypts each file with AES (Fernet) before uploading | +| `Post_Delete_Enc` | After upload, delete the local `.enc` file | +| `Subfolder_Monitor` | Recursively watch subfolders | +| `run_process` | Set to `True` to start uploading | + +3. Run a workflow that generates an image. The AutoUploader picks it up, optionally encrypts it, uploads it to Dropbox, and prints status to the ComfyUI console. --- -## πŸ” Decryption Scripts (Local Use Only) +## Decryption (Local Use Only) -The `/scripts/` folder contains scripts to decrypt `.enc` files on your local machine. +If you uploaded with `enable_encryption=True`, files arrive in Dropbox as `filename.ext.enc`. Decrypt them on your local machine after downloading. -> ⚠️ **LOCAL USE ONLY** β€” Run these after downloading encrypted files from Dropbox. +> **Decrypt files only after they have been moved to your local computer or external drive.** Decrypting on the cloud pod defeats the purpose of encrypting before upload. -> **ONLY DECRYPT ONCE FILES HAVE BEEN MOVED TO YOUR LOCAL COMPUTER OR EXTERNAL HD. OTHERWISE IT DEFEATS THE PURPOSE OF ENCRYPTION BEFORE SENDING TO CLOUD STORAGE.** - -### Prerequisites on local machine for decrypting +### Install the decrypt dependency ```bash pip install cryptography ``` -### Store Your Encryption Key +### Store your encryption key locally **macOS (Keychain):** -1. Open Keychain Access β†’ File β†’ New Password Item -2. Name: `ComfyUI_Encryption_Key`, Account: `ComfyUI`, Password: your key +1. Open Keychain Access, choose File then New Password Item. +2. Name: `ComfyUI_Encryption_Key`. Account: `ComfyUI`. Password: your key. -**Windows (Environment Variable):** -1. Win + R β†’ `sysdm.cpl` β†’ Advanced β†’ Environment Variables -2. New User Variable: `COMFYUI_ENCRYPTION_KEY` = your key +**Windows:** +1. Press `Win + R`, run `sysdm.cpl`, open **Advanced**, click **Environment Variables**. +2. Under **User variables**, click **New**. Name: `COMFYUI_ENCRYPTION_KEY`. Value: your key. -**Linux (Environment Variable):** +**Linux:** ```bash echo 'export COMFYUI_ENCRYPTION_KEY="your_key_here"' >> ~/.bashrc source ~/.bashrc ``` -### Run Decryption +### Run the decrypt script -### Local Decryption (Decrypt Files on Your Computer) +1. Open the `/scripts/` folder in this repository and pick your platform: -Once your encrypted `.enc` files have been downloaded from the cloud, you can decrypt them locally using the platform-specific scripts provided in this repo. + | Platform | File | + |---|---| + | macOS | `mac/decrypt_folder_mac.sh` | + | Windows | `win/decrypt_folder_win.py` | + | Linux | `linux/decrypt_folder_linux.sh` | + | Cross-platform | `decrypt_folder.py` (Python) | -#### Step-by-Step +2. Save the script to a convenient location (your home folder works well). -1. **Go to the [`/scripts`](https://github.com/machinepainting/ComfyUI_DropSendNode/tree/main/scripts) folder** in this repository. +3. Open a terminal in that location and run: -2. **Select your platform folder** (`mac/`, `win/`, or `linux/`) and download the decrypt script for your OS. If you want to use the cross-platform Python version, grab `decrypt_folder.py` from the scripts root instead. - - | Platform | Folder | Script | - |---|---|---| - | macOS | `mac/` | `decrypt_folder_mac.sh` | - | Windows | `win/` | `decrypt_folder_win.py` | - | Linux | `linux/` | `decrypt_folder_linux.sh` | - | Cross-platform | scripts root | `decrypt_folder.py` | - -3. **Download the script** and place it in a convenient location on your computer. Your user root/home directory is recommended for easy terminal access (e.g. `~/` on macOS/Linux or `C:\Users\YourName\` on Windows). - -4. **Open a terminal** and navigate to the folder where you saved the script: - ```bash - # macOS / Linux β€” example if saved to home directory - cd ~/ - - # Windows (Command Prompt) β€” example if saved to user folder - cd C:\Users\YourName\ - ``` - -5. **Run the decryption script:** ```bash # macOS ./decrypt_folder_mac.sh - # Windows - python decrypt_folder_win.py - # Linux ./decrypt_folder_linux.sh - # Cross-platform (Python) + # Windows + python decrypt_folder_win.py + + # Cross-platform Python python decrypt_folder.py ``` -6. **When prompted, drag in (or paste the path to) the folder containing your `.enc` files.** +4. When prompted, drag in (or paste the path to) the folder containing your `.enc` files. -7. **The script will decrypt your files.** Once complete, you'll be asked if you'd like to move the original `.enc` files to a separate folder for cleanup. Note: the script will **never delete your files** β€” you can remove the `.enc` originals manually at your own discretion. +5. The script decrypts each file alongside the original. Once finished, you are asked whether to move the `.enc` originals into a separate cleanup folder. Originals are never deleted automatically. -That's it β€” your decrypted files are now available locally on your machine, off the cloud and ready to use. - -> **πŸ’‘ Manual Encryption:** Each platform folder also contains an encryption script if you ever want to encrypt files manually outside of ComfyUI β€” for example, encrypting media before uploading it yourself. These are separate from the node's built-in encryption, which handles everything automatically during upload. +> Each platform folder also contains an encryption script if you ever want to encrypt files manually outside of ComfyUI. The node itself handles encryption automatically during upload. --- -### Files Not Uploading -- Check console for error messages -- Verify `run_process` is **True** -- Confirm Dropbox folder starts with `/Apps/` +## Troubleshooting -### Encryption Key Not Found -- Verify secret name is exactly `COMFYUI_ENCRYPTION_KEY` -- Check environment variable is set in pod template -- Restart pod after adding environment variables +### Files are not uploading +- Set `run_process` to `True`. +- Check the ComfyUI console for upload errors. +- Confirm the destination folder starts with `/Apps/`. -### Authorization Failed -- Re-run setup with `reconnect` set to **true** -- Generate a new auth code +### "Encryption key not found" +- Confirm the secret name is exactly `COMFYUI_ENCRYPTION_KEY`. +- Verify the env var is set in the pod template (then restart the pod). + +### "Setup is disabled" or "Reconnect refused" + +The Setup Node prints this when `COMFYUI_DROPSEND_ALLOW_SETUP=1` is not visible to the running ComfyUI process. Stop ComfyUI, set the variable in the same terminal you launch from, verify it, and start ComfyUI again. + +```bash +# macOS / Linux +export COMFYUI_DROPSEND_ALLOW_SETUP=1 +echo $COMFYUI_DROPSEND_ALLOW_SETUP # prints: 1 +``` + +```cmd +:: Windows (Command Prompt) +set COMFYUI_DROPSEND_ALLOW_SETUP=1 +echo %COMFYUI_DROPSEND_ALLOW_SETUP% +``` + +```powershell +# Windows (PowerShell) +$env:COMFYUI_DROPSEND_ALLOW_SETUP = "1" +echo $env:COMFYUI_DROPSEND_ALLOW_SETUP +``` + +For RunPod or Docker, add the variable to the pod template and restart. + +> The variable must be set in the **same shell that launches ComfyUI**. Setting it in one terminal and starting ComfyUI from a different one (or via Pinokio, ComfyUI Desktop, a launcher script, etc.) will not work. Adding it to `~/.bashrc` or `~/.zshrc` and opening a fresh terminal also works. + +### "Browser delivery refused" + +The Setup Node printed this because your prompt was submitted without a `client_id` (typical for `curl` or SDK submissions that omit it). Submitting that way would broadcast credentials to every connected WebSocket client, so the node refuses. Submit the Setup workflow from the ComfyUI web UI instead, or switch `storage_method` to `env_file` to write to disk. + +### Browser panel did not appear after `display_only` setup +- Re-run the Setup Node. Credentials are not stored on the pod in this mode, so the panel is the only retrieval path and it regenerates on each run. +- Check the browser's popup or panel blocker. +- If it still does not appear, switch `storage_method` to `env_file` and `cat` the `.env` file from a shell. + +### Authorization failed +- Confirm `COMFYUI_DROPSEND_ALLOW_SETUP=1` is set (see above). +- Re-run the Setup Node with `reconnect=True` to clear stale state, then retry. +- Generate a fresh auth code from Dropbox. --- -## ⚠️ Security Best Practices +## Security Best Practices -1. **Never commit your `.env` file** β€” Add to `.gitignore` -2. **Backup your encryption key** β€” Without it, encrypted files cannot be recovered -3. **Use secure key storage** β€” Prefer Keychain/Environment Variables over plain text +1. **Never commit your `.env` file.** It is already in `.gitignore`. +2. **Back up your encryption key.** Without it, encrypted files cannot be recovered. +3. **Prefer Keychain or environment variables** over plaintext storage of the key. +4. **Bind ComfyUI to localhost or front it with an authenticating proxy.** ComfyUI's `/prompt` endpoint accepts any submission unauthenticated; on a publicly-reachable host that means anyone can queue workflows on your machine. Either bind to `127.0.0.1` (default) and tunnel in via SSH/VPN, or place ComfyUI behind a reverse proxy that enforces HTTP basic auth or OAuth. The DropSend protections layered on top assume the operator is the only person with workflow-submit access; without that, the `COMFYUI_DROPSEND_ALLOW_SETUP` gate is your last line of defense. + +### Encryption key rotation + +The Fernet key in `COMFYUI_ENCRYPTION_KEY` is symmetric: the same key encrypts and decrypts. Practical implications: + +- **Old `.enc` files cannot be decrypted with a new key.** If you rotate the key while previously-encrypted files still live in your Dropbox, those files become permanently unreadable unless you keep the old key on hand (e.g. in a labelled backup). Decide before rotating: re-download and re-encrypt against the new key, accept the loss, or stash the old key in a labelled secrets vault entry (e.g. `COMFYUI_ENCRYPTION_KEY_2025_archive`). +- **A leaked key compromises everything encrypted under it.** If you suspect the key has been exposed (committed to git, pasted into a chat, etc.), generate a fresh key, re-encrypt files you need going forward, and delete or replace the old `.enc` files in Dropbox. The leaked key remains valid for any copy of the old files an attacker may already have. +- **Generating a new key.** Re-run the Setup Node with `encryption_key_method = Display Only` (or `save to .env`). Each run generates a fresh `Fernet.generate_key()`. Copy the new value into your secrets store and restart the pod. + +#### Recovering files encrypted with an old key + +If you have `.enc` files that were encrypted with an older key (whether you rotated deliberately or are upgrading from v1.0.x), this is the recovery process: + +1. **Locate the old key.** Wherever you stored it: password manager, secrets vault, old `.env` file, screenshot of the original Setup Node output panel. The Fernet key looks like `Q2xVZi1...rjs=` (Base64, 44 characters). +2. **Download the `.enc` files locally.** Don't decrypt on the cloud pod, that re-introduces the plaintext to the pod's filesystem. +3. **Set the OLD key as `COMFYUI_ENCRYPTION_KEY` in your local shell**: + + ```bash + # macOS / Linux (one-shot, just for the decrypt session) + export COMFYUI_ENCRYPTION_KEY="" + ``` + ```cmd + :: Windows Command Prompt + set COMFYUI_ENCRYPTION_KEY= + ``` + +4. **Run the decrypt script** from the [Decryption section](#decryption-local-use-only) above against the folder of `.enc` files. The script reads `COMFYUI_ENCRYPTION_KEY` from the environment, so whichever key is currently set is the one used. +5. **(Optional) Re-encrypt with the new key.** If you want the recovered files re-uploaded to Dropbox under the new key, swap `COMFYUI_ENCRYPTION_KEY` to the new value and run the matching encrypt script in `scripts//`. +6. **Wipe the old key from your shell** when finished: + + ```bash + unset COMFYUI_ENCRYPTION_KEY # macOS / Linux + set COMFYUI_ENCRYPTION_KEY= # Windows cmd + ``` + +If the old key was leaked (per the v1.1.0 advisory below) and you're recovering files only because you wanted them back: be aware that anyone with a copy of both the leaked key AND a copy of the `.enc` file can decrypt it. Re-uploading under a fresh key only protects the *new* uploads, not the old `.enc` files that were already in attacker-reachable storage. + +### Uninstalling / disconnecting + +When you stop using DropSend (uninstalling the plugin, decommissioning a pod, etc.), do these steps in order so leaked credentials cannot be reused: + +1. **Disconnect the Dropbox app from your account.** Go to , find the app, and click **Disconnect**. This invalidates the refresh token at Dropbox; even a copy of the token sitting in someone's clipboard becomes useless. *(Running the Setup Node with `reconnect=True` does this automatically via the API β€” same effect, different trigger.)* +2. **Reset the app secret.** Go to , click your app β†’ **Settings** β†’ next to **App secret**, click **Show**, then **Reset**. This invalidates any cached secret that was paired with the (now-revoked) refresh token. Without both halves, no further token refresh is possible even if the old values were captured. +3. **Remove credentials from your platform's secrets store.** Delete `DROPBOX_APP_KEY`, `DROPBOX_APP_SECRET`, `DROPBOX_REFRESH_TOKEN`, and `COMFYUI_ENCRYPTION_KEY` from RunPod Secrets / Docker env / systemd EnvironmentFile / wherever they're configured. Restart the pod after removal so the env vars are no longer in process memory. +4. **Delete `.env`** from the plugin directory if you used `storage_method = env_file`. +5. **Decide what to do with previously-uploaded `.enc` files.** Files already in Dropbox under your account were encrypted by the operator who held `COMFYUI_ENCRYPTION_KEY`. If that key was leaked, anyone with the key can still decrypt the files. Delete files you don't need, or re-download and re-encrypt with a fresh key. + +### Threat model for network-reachable ComfyUI hosts + +If your ComfyUI is reachable over the network (RunPod, tunnels, LAN, public web UI), anyone who can submit a workflow can in principle set node inputs. To keep DropSend safe in that setting, the nodes enforce these protections. + +- **`watch_folder` is clamped to the ComfyUI output directory.** The AutoUploader runs a recursive Watchdog observer that uploads everything it sees, so an unrestricted path would be an arbitrary-file-read primitive. By default, `watch_folder` must resolve inside the directory returned by `folder_paths.get_output_directory()`. To monitor an additional location, set `COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS` on the host (`os.pathsep`-separated absolute paths) before starting ComfyUI. Workflow inputs cannot expand this list. + +- **The Setup Node refuses to write or clear credentials unless explicitly opted in.** This is what `COMFYUI_DROPSEND_ALLOW_SETUP=1` enforces. A remote workflow submitter can send JSON to ComfyUI but cannot set environment variables on your machine, so the gate proves a human with host access opted in. The AutoUploader never needs this flag. + +- **Setup Node secret fields are not workflow inputs.** `app_key`, `app_secret`, and `auth_code` do not appear in the node's `INPUT_TYPES`. They are entered only via a browser-only modal and POSTed to a same-origin route (`/dropsend/setup/stash`) that is gated on a live WebSocket session. They never enter the workflow JSON, PNG metadata, ComfyUI's localStorage auto-save, copy-pasted nodes, or the unauthenticated `/history` endpoint. + +- **The stash route is hardened.** Same-origin check (the `Origin` header must match the request `Host`, blocking cross-origin CSRF from another tab), `Content-Type: application/json` requirement, live-session check (the supplied `client_id` must match a connected WebSocket), per-IP rate limit (30 POSTs per 10 seconds), 32 KB body cap, JSON validation, 32-entry capacity with 60-second TTL, lock-protected access, one-shot consumption when `setup()` runs. + +- **Credentials never travel through the node's `ui` or `result` channels.** ComfyUI persists both into `PromptServer.history`, served on the unauthenticated `/history` HTTP endpoint. Instead the Setup Node delivers credentials via: + - **`env_file`** (local installs only): values are written to `.env` in the plugin directory (mode `0600`, race-free open) and the node returns only a non-secret confirmation. + - **`display_only`** (cloud or hosted ComfyUI): values are pushed to the originating browser session via a one-shot WebSocket message and rendered in a panel inside the workflow tab. Nothing is written to disk on the pod. The panel discards the data when closed. + +- **WebSocket deliveries are sid-targeted, never broadcast.** ComfyUI's `send_sync(event, data, sid=None)` would broadcast to every connected client when `sid` is `None`. The Setup Node refuses to send credential or refresh notifications without a `client_id` and returns a clear "Browser delivery refused" message. As defense in depth, the originating `client_id` is echoed inside each WebSocket payload and the JS handler verifies it matches `api.clientId` before rendering the credentials panel. + +- **Reconnect revokes the Dropbox refresh token at the source.** Running Setup with `reconnect=True` calls `POST /2/auth/token/revoke` against `api.dropboxapi.com` (8-second timeout) so the previously-issued refresh token cannot be reused even if it leaked out of band. Local cleanup proceeds regardless of revocation outcome. + +- **Logging stores no secrets.** The plugin's `dropsend.log` records file paths, watcher events, and errors. No tokens, refresh tokens, app secrets, or encryption keys are written to logs or stdout in plaintext. The post-setup banner uses last-4-character redaction so values are not captured by stdout aggregators. The log uses `RotatingFileHandler` (5 MB per file, 3 backups) so it cannot grow unbounded; on a long-lived host disk usage is capped at roughly 20 MB. + +### Additional considerations + +- **`COMFYUI_DROPSEND_ALLOW_SETUP` is process-wide, not per-action.** Once set, every workflow submission in that ComfyUI process is unblocked, including hostile submissions during the setup window. Recommended discipline: set the gate, run Setup once, restart ComfyUI without the gate. + +- **The OAuth authorization URL contains your Dropbox `app_key`.** Dropbox treats `app_key` as a public identifier (it appears in every authorization URL), but the URL string itself ends up in `/history` when the Setup Node returns it during the first run. This reveals which Dropbox app the host is paired with. It does not, on its own, allow access to your data. + +- **Plain ComfyUI runs over HTTP.** The browser-only credential delivery in `display_only` mode rides the same WebSocket the rest of ComfyUI uses, in cleartext on the wire. On a network you do not fully trust, terminate ComfyUI behind HTTPS (reverse proxy, tunnel) before relying on this path. The most conservative alternative is to run the Setup Node on a local ComfyUI install and copy the values into your cloud secrets manager directly. Credentials never touch the cloud pod's filesystem or its network. + +- **Multi-tenant hosts: the plugin directory listing is visible.** Plugin files are installed `0o755` (world-readable directory listing). The `.env` inside is `0o600` so its contents are protected, but other local users can run `ls` on the plugin dir and see that you have a `.env` (signaling "this user is paired with Dropbox"). Single-user machines are unaffected. On a multi-tenant box, set the plugin directory to `0o700` if your ComfyUI process runs as the only consumer, or accept the metadata leak. + +- **Reconnect-revocation can fail silently if Dropbox is unreachable.** If `reconnect=True` cannot mint a fresh access token (network outage, refresh token already invalidated, etc.), local cleanup proceeds but the token at Dropbox stays alive. The failure is logged at `ERROR` level (`dropsend.log` and stdout) with explicit instructions to manually disconnect at . **Watch for the "Token revocation FAILED" line** when reconnecting; if you see it, finish the disconnect manually. + +- **Dropbox SDK error messages may appear in `dropsend.log`.** When an upload or token-refresh call fails, the SDK exception is logged. Most failure modes don't include credential material, but reviewing the log before sharing it (e.g. with support) is good practice. --- -## πŸ§ͺ Tested On +## Tested On - macOS 13+ (Ventura, Sonoma) - Python 3.10 / 3.11 -- ComfyUI (Feb 2026) +- ComfyUI (May 2026) - RunPod GPU instances --- -## πŸ“ Repository Structure +## Repository Structure ``` ComfyUI_DropSendNode/ @@ -317,10 +473,12 @@ ComfyUI_DropSendNode/ β”œβ”€β”€ dropbox_auth_manager.py β”œβ”€β”€ encrypt_file.py β”œβ”€β”€ monitor_output.py -β”œβ”€β”€ oauth_handler.py +β”œβ”€β”€ safe_paths.py β”œβ”€β”€ requirements.txt β”œβ”€β”€ README.md β”œβ”€β”€ .gitignore +β”œβ”€β”€ web/ +β”‚ └── dropbox_oauth.js └── scripts/ β”œβ”€β”€ decrypt_folder.py β”œβ”€β”€ mac/ @@ -336,10 +494,36 @@ ComfyUI_DropSendNode/ --- +## Changelog + +### v1.1.0 β€” Setup Node refactor + security hardening + +**Security fixes (advisory):** earlier versions of the Setup Node accepted `app_key`, `app_secret`, and `auth_code` as workflow inputs and delivered credentials over a `send_sync(event, data)` call without an explicit `sid` argument. On ComfyUI hosts reachable over a network, both paths leaked credentials: + +- **WebSocket broadcast.** When `PromptServer.client_id` was `None` (typical for CLI/API submissions that omit `client_id`), `send_sync(..., None)` broadcast credential payloads to every connected browser tab on the same instance. +- **Workflow persistence.** `app_key`/`app_secret`/`auth_code` typed into the node's text fields were serialized into saved workflow JSON, ComfyUI's localStorage auto-save, embedded PNG metadata of any image generated during the session, copy-pasted nodes, and the unauthenticated `/history` HTTP endpoint. + +If you ran a previous version of the Setup Node on a network-reachable host, treat the corresponding `app_key` / `app_secret` / `refresh_token` as compromised: revoke the Dropbox app, reset the app secret, and re-issue a refresh token. See **Uninstalling / disconnecting** above. + +**v1.1.0 closes both vectors via:** +- Removing `app_key` / `app_secret` / `auth_code` from the Setup Node's `INPUT_TYPES` entirely. Credentials are now entered through a browser-only modal that POSTs to a same-origin route, and never become workflow inputs at any layer. +- Refusing to call `send_sync` for credential events when `client_id` is `None`. Defense in depth: the `client_id` is echoed inside each WebSocket payload and verified by the JS handler against `api.clientId` before rendering the credentials modal. +- Hardening the new `/dropsend/setup/stash` route with a live-WebSocket-session check, per-IP rate limit, body cap, and 60-second TTL on stashed entries. +- Revoking the Dropbox refresh token at the source on `reconnect=True` (8-second timeout, local cleanup proceeds regardless). + +**Other changes in this release:** +- `folder_format` dropdown for AutoUploader (`project_name` default + five worldwide date formats). +- Single-Observer fix for the macOS `_fsevents` conflict that had prevented `.enc` files from being picked up by the upload watcher. +- Auto-trigger modal after Run 1 prints the OAuth URL banner. +- Branded `πŸ“¦πŸ‘€ / πŸ“¦πŸ” / πŸ“¦πŸ“€` log emojis for watching/encrypted/sent. +- Centralized logging in `__init__.py` so log records actually persist to `dropsend.log`. + +--- + ## License MIT --- -Shout-out to Adam for his contributions to this node build! +Shout-out to Adam for his contributions to this node build. diff --git a/__init__.py b/__init__.py index 6afe825..78bcfe8 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,74 @@ # ComfyUI_DropSendNode/__init__.py # Register DropSend Setup + AutoUploader nodes +import os +import logging +import logging.handlers +from dotenv import load_dotenv + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +# Attach a FileHandler to the root logger so every child logger in this +# plugin (`logging.getLogger(__name__)` in our modules) gets its records +# persisted to dropsend.log alongside ComfyUI's own stdout. We do NOT +# use logging.basicConfig() here: ComfyUI's main.py configures the root +# logger before our package is imported, and basicConfig is a no-op +# once any caller has already configured root. +# +# Race-free perms: pre-create the file with 0o600 via os.open() before +# FileHandler can create it under the default umask (typically 0o644). +# A follow-up chmod tightens perms if the file already existed with +# looser bits. +_LOG_PATH = os.path.join(os.path.dirname(__file__), "dropsend.log") +try: + _fd = os.open(_LOG_PATH, os.O_CREAT | os.O_WRONLY | os.O_APPEND, 0o600) + os.close(_fd) +except OSError: + pass +_root_logger = logging.getLogger() +if not any( + isinstance(h, logging.FileHandler) + and getattr(h, "baseFilename", "") == _LOG_PATH + for h in _root_logger.handlers +): + try: + # Rotating handler caps disk use: 5 MB per file, 3 backups + + # current = 4 files = ~20 MB max. On a busy host, plain + # FileHandler grows unbounded over months; rotation prevents + # a tiny background process from filling someone's home dir. + _fh = logging.handlers.RotatingFileHandler( + _LOG_PATH, + maxBytes=5 * 1024 * 1024, + backupCount=3, + ) + _fh.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + _root_logger.addHandler(_fh) + # If ComfyUI didn't set a root level (or set it higher than INFO), + # raise enough that our INFO records actually reach the handler. + if _root_logger.level == logging.NOTSET or _root_logger.level > logging.INFO: + _root_logger.setLevel(logging.INFO) + except OSError: + pass +try: + os.chmod(_LOG_PATH, 0o600) +except OSError: + pass + +# --------------------------------------------------------------------------- +# .env loading +# --------------------------------------------------------------------------- +# Load .env from the plugin directory at import time so DROPBOX_APP_KEY, +# DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN, COMFYUI_ENCRYPTION_KEY, and +# the uploader's persisted settings are visible in os.environ before any +# node runs. override=False is intentional β€” host-injected env vars +# (RunPod secrets, Docker env, systemd EnvironmentFile) take precedence +# over the plugin's local .env. +_PLUGIN_ENV = os.path.join(os.path.dirname(__file__), ".env") +if os.path.exists(_PLUGIN_ENV): + load_dotenv(_PLUGIN_ENV, override=False) + from .dropsend_uploader_node import NODE_CLASS_MAPPINGS as RUN_CLASS, NODE_DISPLAY_NAME_MAPPINGS as RUN_DISPLAY from .dropsend_setup_node import NODE_CLASS_MAPPINGS as SETUP_CLASS, NODE_DISPLAY_NAME_MAPPINGS as SETUP_DISPLAY NODE_CLASS_MAPPINGS = {} @@ -10,18 +79,3 @@ NODE_DISPLAY_NAME_MAPPINGS.update(RUN_DISPLAY) NODE_DISPLAY_NAME_MAPPINGS.update(SETUP_DISPLAY) # Define web directory for JavaScript extensions WEB_DIRECTORY = "./web" -# Register OAuth callback route with ComfyUI server -def register_oauth_routes(): - """Register OAuth callback routes with ComfyUI's PromptServer""" - try: - from server import PromptServer - from .oauth_handler import handle_oauth_callback - - # Add the OAuth callback route - PromptServer.instance.app.router.add_get('/oauth/dropbox/callback', handle_oauth_callback) - print("[DropSend] Registered OAuth callback route: /oauth/dropbox/callback") - - except Exception as e: - print(f"[DropSend] Warning: Could not register OAuth routes: {e}") -# Register routes when the node is loaded -register_oauth_routes() diff --git a/dropbox_auth_manager.py b/dropbox_auth_manager.py index 28a82c1..3fb34a7 100644 --- a/dropbox_auth_manager.py +++ b/dropbox_auth_manager.py @@ -1,107 +1,31 @@ # dropbox_auth_manager.py import os +import logging import requests import json +logger = logging.getLogger(__name__) + class DropboxAuthManager: - def __init__(self, app_key=None, app_secret=None): - # Always store provided credentials + def __init__(self, app_key=None, app_secret=None, refresh_token=None): self.app_key = app_key self.app_secret = app_secret - self.refresh_token = None - - # Try to load from file storage if no credentials provided - if app_key is None or app_secret is None: - self._try_load_from_file() - + self.refresh_token = refresh_token + def _get_credentials_file_path(self): - """Get path to credentials file""" + """Path to a legacy JSON credential file from older versions. + + The current code never writes this file β€” credentials live in + `.env` (or are delivered to the browser via WebSocket and never + touch disk). The path is kept only so reset() can clean up a + stale file left over from earlier installs. + """ plugin_dir = os.path.dirname(os.path.abspath(__file__)) return os.path.join(plugin_dir, ".dropbox_credentials.json") - - def _try_load_from_file(self): - """Try to load credentials from JSON file""" - try: - credentials_path = self._get_credentials_file_path() - if os.path.exists(credentials_path): - with open(credentials_path, 'r') as f: - creds = json.load(f) - - if self.app_key is None: - self.app_key = creds.get("app_key") - if self.app_secret is None: - self.app_secret = creds.get("app_secret") - if self.refresh_token is None: - self.refresh_token = creds.get("refresh_token") - - print(f"[DropboxAuthManager] Loaded credentials from file: {credentials_path}") - - except Exception as e: - print(f"[DropboxAuthManager] Could not load from credentials file: {e}") def is_connected(self): - # Load from file if we don't have credentials yet - if not (self.app_key and self.app_secret and self.refresh_token): - self._try_load_from_file() return bool(self.app_key and self.app_secret and self.refresh_token) - def store_tokens(self, app_key, app_secret, refresh_token): - """Store tokens in JSON file""" - try: - credentials_path = self._get_credentials_file_path() - - credentials = { - "app_key": app_key, - "app_secret": app_secret, - "refresh_token": refresh_token - } - - print(f"[DropboxAuthManager] Storing tokens in file: {credentials_path}") - with open(credentials_path, 'w') as f: - json.dump(credentials, f, indent=2) - - # Set appropriate file permissions (readable only by owner) - os.chmod(credentials_path, 0o600) - - # Update instance variables after successful storage - self.app_key = app_key - self.app_secret = app_secret - self.refresh_token = refresh_token - print(f"[DropboxAuthManager] Tokens stored successfully in credentials file") - - except Exception as e: - error_msg = f"Failed to store Dropbox tokens: {e}" - print(f"[DropboxAuthManager] {error_msg}") - raise RuntimeError(error_msg) - - def exchange_auth_code(self, auth_code, redirect_uri=None): - if not (self.app_key and self.app_secret): - raise ValueError("App key and secret must be set before exchanging auth code.") - - data = { - "code": auth_code, - "grant_type": "authorization_code", - "client_id": self.app_key, - "client_secret": self.app_secret - } - - # Include redirect_uri if it was used in the authorization request - if redirect_uri: - data["redirect_uri"] = redirect_uri - - headers = {"Content-Type": "application/x-www-form-urlencoded"} - response = requests.post("https://api.dropbox.com/oauth2/token", headers=headers, data=data) - response.raise_for_status() - - creds = response.json() - refresh_token = creds.get("refresh_token") - if not refresh_token: - raise RuntimeError("Failed to obtain refresh token from Dropbox.") - - self.store_tokens(self.app_key, self.app_secret, refresh_token) - self.refresh_token = refresh_token - return True - def exchange_auth_code_raw(self, auth_code, redirect_uri=None): """Exchange auth code for tokens without storing them""" if not (self.app_key and self.app_secret): @@ -119,34 +43,29 @@ class DropboxAuthManager: data["redirect_uri"] = redirect_uri headers = {"Content-Type": "application/x-www-form-urlencoded"} - - print(f"[DropboxAuthManager] Token exchange request data: {data}") - print(f"[DropboxAuthManager] Making request to: https://api.dropbox.com/oauth2/token") - + + # Do not log `data` or `response.text` here β€” both contain secrets + # (client_secret, refresh_token, access_token). On platforms that + # capture stdout (RunPod, Docker, CI) those would land in shared logs. response = requests.post("https://api.dropbox.com/oauth2/token", headers=headers, data=data) - - print(f"[DropboxAuthManager] Response status: {response.status_code}") - print(f"[DropboxAuthManager] Response text: {response.text}") - - # If 400 error, try without it. - # This handles cases where the auth code was obtained without a callback URL + + print(f"[DropboxAuthManager] Token exchange response status: {response.status_code}") + + # If 400 error, try without redirect_uri. This handles cases where + # the auth code was obtained without a callback URL. if response.status_code == 400 and redirect_uri: - print(f"[DropboxAuthManager] 400 error with callback URL, trying without callback URL...") - + print("[DropboxAuthManager] 400 with redirect_uri, retrying without it") + data_without_redirect = { "code": auth_code, "grant_type": "authorization_code", "client_id": self.app_key, "client_secret": self.app_secret } - - print(f"[DropboxAuthManager] Retry request data (no callback URL): {data_without_redirect}") - + response = requests.post("https://api.dropbox.com/oauth2/token", headers=headers, data=data_without_redirect) - print(f"[DropboxAuthManager] Retry response status: {response.status_code}") - print(f"[DropboxAuthManager] Retry response text: {response.text}") - + response.raise_for_status() return response.json() @@ -174,12 +93,10 @@ class DropboxAuthManager: """Clear stored tokens and optionally revoke authorization with Dropbox""" if revoke_token: - # Load credentials from file first if we don't have them in memory - if not (self.refresh_token and self.app_key and self.app_secret): - print("[DropboxAuthManager] Loading credentials for token revocation...") - self._try_load_from_file() - - # First, revoke the token with Dropbox if requested and possible + # Revoke the token with Dropbox if we have it in memory. + # The legacy auto-load from .dropbox_credentials.json is gone; + # callers that want to revoke must construct this manager with + # creds (or set them) before calling reset(revoke_token=True). if self.refresh_token and self.app_key and self.app_secret: try: print("[DropboxAuthManager] Revoking token with Dropbox...") @@ -199,17 +116,41 @@ class DropboxAuthManager: response = requests.post( "https://api.dropboxapi.com/2/auth/token/revoke", headers=headers, - json=revoke_data + json=revoke_data, + # Bound the wait so a Dropbox outage or DNS + # failure doesn't hang the user mid-reconnect. + # Local-cleanup is the priority here; the + # token-revoke is best-effort (and Dropbox + # tokens expire on their own anyway). + timeout=8, ) if response.status_code == 200: - print("[DropboxAuthManager] Token successfully revoked with Dropbox") + msg = "[DropboxAuthManager] Token successfully revoked with Dropbox" + print(msg) + logger.info(msg) else: - print(f"[DropboxAuthManager] Token revocation failed: {response.status_code} - {response.text}") - + # Don't log response.text β€” it can echo the access token on some failures. + msg = ( + f"[DropboxAuthManager] Token revocation FAILED at Dropbox " + f"(HTTP {response.status_code}). The refresh token may still " + f"be valid. Manually disconnect at " + f"https://www.dropbox.com/account/connected_apps to be sure." + ) + print(msg) + logger.error(msg) except Exception as e: - print(f"[DropboxAuthManager] Could not revoke token with Dropbox: {e}") - # Continue with local cleanup even if revocation fails + # Common case: Dropbox unreachable, refresh token already + # invalidated, or get_access_token() couldn't mint one. + # Local cleanup proceeds, but the operator needs to know + # the token may still be live at Dropbox. + msg = ( + f"[DropboxAuthManager] Token revocation FAILED locally: {e}. " + f"The refresh token may still be valid at Dropbox. Manually " + f"disconnect at https://www.dropbox.com/account/connected_apps." + ) + print(msg) + logger.error(msg) else: print("[DropboxAuthManager] Skipping token revocation (revoke_token=False)") diff --git a/dropbox_upload.py b/dropbox_upload.py index 4a06030..af5f6f3 100644 --- a/dropbox_upload.py +++ b/dropbox_upload.py @@ -6,14 +6,7 @@ import requests from dropbox.exceptions import ApiError import logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('dropsend.log'), - logging.StreamHandler() - ] -) +# Logging configured by the package __init__ β€” see __init__.py. logger = logging.getLogger(__name__) def get_token(): @@ -81,7 +74,7 @@ def upload_to_dropbox(local_file_path, dropbox_folder="/ComfyUI_Output_Files"): if local_hash != remote_hash: raise Exception(f"Upload integrity check failed for {dropbox_path}") - logger.info(f"πŸ“¦βœ… Upload Verified: {dropbox_path}") + logger.info(f"πŸ“¦πŸ“€ Sent: {dropbox_path}") return True # Indicate successful upload and verification except Exception as e: logger.error(f"Upload failed: {str(e)}") diff --git a/dropsend_setup_node.py b/dropsend_setup_node.py index 9ef5860..d908aad 100644 --- a/dropsend_setup_node.py +++ b/dropsend_setup_node.py @@ -1,6 +1,9 @@ # dropsend_setup_node.py import os +import re +import time +import threading import requests import webbrowser import uuid @@ -8,25 +11,265 @@ import json from dotenv import load_dotenv, dotenv_values import urllib.parse from .dropbox_auth_manager import DropboxAuthManager -from .oauth_handler import OAuthCallbackHandler, get_server_base_url from cryptography.fernet import Fernet + +# --------------------------------------------------------------------------- +# Out-of-band secret transport +# +# ComfyUI persists every queued prompt's input JSON in +# PromptServer.history, which is exposed on the unauthenticated +# /history endpoint. If the Setup Node accepts app_key/app_secret/ +# auth_code as workflow inputs, those values are visible there for the +# lifetime of history entry β€” a real exposure on any network-reachable +# pod (RunPod tunnel, public ComfyUI UI, etc.). +# +# To close it: the JS extension intercepts Queue, POSTs the secret +# widget values to /dropsend/setup/stash (this module's route), clears +# the widgets, then lets the queue go through. The prompt JSON ComfyUI +# stores in history therefore has empty strings for the secret fields. +# When this node's setup() actually runs, it pulls the real values +# from the stash (one-shot, keyed by the originating client_id). +# +# Entries auto-expire after _SETUP_STASH_TTL_SEC so a stash POST that's +# never consumed (user closed the tab between stash and queue, etc.) +# doesn't sit in memory. +# --------------------------------------------------------------------------- + +_setup_secret_stash = {} +_setup_secret_lock = threading.Lock() +_SETUP_STASH_TTL_SEC = 60 +_SETUP_STASH_MAX_ENTRIES = 32 # cap to bound memory under stash-spam abuse +_CLIENT_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]{1,128}$") + +# Per-IP rate limit on the stash route. Defense-in-depth against an +# attacker on the same network firing thousands of stash POSTs to +# evict a legitimate user's entry between Save and Queue (the entry +# eviction policy is oldest-first to favour fresh writes, but a +# sufficiently fast attacker can still race). +_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): + """Return True if `remote_ip` is under the per-window POST budget, + False if it should be 429'd. Updates the bucket in place.""" + if not remote_ip: + return True # don't block requests we can't attribute + now = time.time() + cutoff = now - _STASH_RL_WINDOW_SEC + with _stash_rl_lock: + # Lazy GC: only walk the dict every ~50 hits, otherwise this + # is O(N) per request and turns into its own DoS. + 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, []) + # Drop hits outside the window. + 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: + # Evict the oldest entry rather than refuse the write β€” the + # legitimate user's most recent attempt should win over + # whatever's been sitting around. + 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 /dropsend/setup/stash POST handler on PromptServer. + + Idempotent β€” repeat calls are no-ops. Called at module import; if + PromptServer isn't ready yet, registration is silently skipped (the + Setup Node falls back to reading secrets from prompt inputs, the + legacy/insecure path). + """ + 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("/dropsend/setup/stash") + async def _stash_handler(request): + # Same-origin enforcement. The browser sends an Origin header on + # POSTs; we require it to match the host the request was made to. + # Without this, a malicious page in another tab could attempt a + # CSRF POST against http://127.0.0.1:8188 β€” and while the live- + # WebSocket-session check would block injection without a known + # client_id, an attacker who sniffed the WS could still try. + # Origin check closes the cross-origin path entirely. + 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) + + # Content-Type lockdown: only accept JSON. Closes the confused- + # deputy class where a form-encoded body is parsed as JSON. + if request.content_type and request.content_type != "application/json": + return web.json_response( + {"error": "Content-Type must be application/json"}, + status=415, + ) + + # Per-IP rate limit. Defense-in-depth against stash-spam DoS + # attempting to evict a legitimate user's entry by overflowing + # the entry cap. + 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) + + # Reject oversized bodies before json-decoding to avoid a + # malicious large-payload memory spike. + 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 = data.get("client_id") if isinstance(data, dict) else None + if not client_id or not isinstance(client_id, str) or not _CLIENT_ID_PATTERN.match(client_id): + 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. Closes the "attacker on the + # same network injects credentials by guessing a victim's + # client_id" vector β€” the attacker would need both the + # client_id AND a live WS bound to it under that ID. + try: + sockets = getattr(instance, "sockets", None) + if not sockets or client_id 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 = { + "app_key": str(data.get("app_key") or ""), + "app_secret": str(data.get("app_secret") or ""), + "auth_code": str(data.get("auth_code") or ""), + } + # Drop entries that have nothing to stash, saves memory and + # avoids weird empty-stash-overrides-real-input behaviour. + if not any(payload.values()): + return web.json_response({"ok": True, "stashed": False}) + _stash_set(client_id, payload) + return web.json_response({"ok": True, "stashed": True}) + + _register_stash_route_once._registered = True + print("[DropSend Setup] Registered /dropsend/setup/stash route (out-of-band secret transport)") + + +_register_stash_route_once() + + +# 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 (a) wipe credentials via reconnect=True or (b) overwrite the +# stored Dropbox app to redirect uploads to their own account. We therefore +# require the operator to opt in by setting this env var on the host before +# the setup node will perform any state-changing action. +_SETUP_OPT_IN_VAR = "COMFYUI_DROPSEND_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 DROPBOX_APP_KEY / DROPBOX_APP_SECRET / " + f"DROPBOX_REFRESH_TOKEN directly via environment variables (e.g. RunPod " + f"secrets). This guard prevents remote workflow submitters from " + f"hijacking or wiping your Dropbox connection." +) + + +def _setup_opt_in_enabled(): + return os.getenv(_SETUP_OPT_IN_VAR, "").strip().lower() in ("1", "true", "yes") + + +# Names of fields treated as secrets when redacting the console banner. +# Every field the Setup Node emits is a credential, so all four are +# redacted. The destination folder is owned by the AutoUploader and +# never passes through this node. +_SECRET_FIELDS = { + "DROPBOX_APP_KEY", + "DROPBOX_APP_SECRET", + "DROPBOX_REFRESH_TOKEN", + "COMFYUI_ENCRYPTION_KEY", +} + + +def _redact(value, visible_tail=4): + """Return value with all but the last few characters masked.""" + 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): + """Redact a 'NAME=value' line if NAME is in _SECRET_FIELDS.""" + name, sep, value = line.partition("=") + if sep and name in _SECRET_FIELDS: + return f"{name}={_redact(value)}" + return line + class DropSendSetupNode: @classmethod def INPUT_TYPES(cls): - # Always show all fields for simplicity - no dynamic field hiding + # Architecture: this node has NO credential-bearing inputs. + # app_key, app_secret, and auth_code never enter the workflow + # prompt JSON, never reach PromptServer.history, never get + # serialized into PNG metadata or saved workflows. They are + # entered by the user in a browser-only modal launched from + # the JS extension's "Set credentials…" button, POSTed + # directly to the same-origin route /dropsend/setup/stash, and + # consumed once on the next Queue from the in-process stash + # keyed by client_id. The only inputs on the node are + # configuration knobs that aren't sensitive. return { "required": { - "app_key": ("STRING", {"default": "", "multiline": False}), - "app_secret": ("STRING", {"default": "", "multiline": False}), - "auth_code": ("STRING", {"default": "", "multiline": False}), - "dropbox_dest_folder": ("STRING", {"default": "/Apps/ComfyUI_Output_Files"}), - }, - "optional": { - "reconnect": ("BOOLEAN", { - "label": "Reset stored credentials", - "default": False - }), "storage_method": (["env_file", "display_only"], { "label": "Credential Storage Method", "default": "display_only" @@ -34,7 +277,13 @@ class DropSendSetupNode: "encryption_key_method": (["off", "Display Only", "save to .env"], { "label": "Encryption Key Method", "default": "Display Only" - }) + }), + }, + "optional": { + "reconnect": ("BOOLEAN", { + "label": "Reset stored credentials", + "default": False + }), } } @@ -42,47 +291,128 @@ class DropSendSetupNode: OUTPUT_NODE = True FUNCTION = "setup" - def setup(self, dropbox_dest_folder, app_key=None, app_secret=None, auth_code=None, reconnect=False, storage_method="display_only", encryption_key_method="Display Only"): + @classmethod + def IS_CHANGED(cls, *args, **kwargs): + # Force re-execution on every Queue. Setup is intentionally + # side-effecting (writes/clears .env, exchanges auth codes, + # refreshes tokens) and the input dict here doesn't reflect + # the actual work β€” the real driver is the out-of-band stash + # entry, which ComfyUI's input-hash cache cannot see. Always + # returning NaN means setup() runs every time the user clicks + # Queue, which is the correct semantics for this node. + return float("NaN") + + def setup(self, storage_method="display_only", encryption_key_method="Display Only", reconnect=False): try: - print(f"[DropSend Setup] Called with:") - print(f" app_key: '{app_key}' (type: {type(app_key)}, bool: {bool(app_key)})") - print(f" app_secret: '{app_secret}' (type: {type(app_secret)}, bool: {bool(app_secret)})") - print(f" auth_code: '{auth_code}' (type: {type(auth_code)}, bool: {bool(auth_code)})") - print(f" reconnect: {reconnect}") - print(f" storage_method: {storage_method}") - print(f" encryption_key_method: {encryption_key_method}") - + print(f"[DropSend Setup] Called: reconnect={reconnect}, storage_method={storage_method}, encryption_key_method={encryption_key_method}") + + # Credentials live exclusively in the per-session out-of-band + # stash keyed by client_id, populated by the browser modal + # via POST /dropsend/setup/stash. There are no fallback + # paths β€” if the stash is empty for this client, the user + # has to click "Set credentials…" on the node and enter + # values there. This is the architectural guarantee that + # /history, PNG metadata, workflow JSON, and localStorage + # never see the secret fields. + app_key = None + app_secret = None + auth_code = None + try: + from server import PromptServer + sid = getattr(PromptServer.instance, "client_id", None) + if sid: + stashed = _stash_consume(sid) + if stashed: + app_key = stashed.get("app_key") or None + app_secret = stashed.get("app_secret") or None + auth_code = stashed.get("auth_code") or None + print("[DropSend Setup] Loaded secrets from out-of-band stash") + except Exception as e: + print(f"[DropSend Setup] Warning: could not consume secret stash: {e}") + + print(f"[DropSend Setup] Inputs present: app_key={bool(app_key)}, app_secret={bool(app_secret)}, auth_code={bool(auth_code)}") + # Initialize auth manager auth_manager = DropboxAuthManager() print(f"[DropSend Setup] Auth manager initialized") # Handle reconnect/reset request if reconnect: + if not _setup_opt_in_enabled(): + message = ( + "Reconnect refused: " + _SETUP_OPT_IN_HELP + ) + print(f"[DropSend Setup] {message}") + return {"ui": {"text": [message]}, "result": (message,)} + print("[DropSend Setup] Reconnect requested - clearing all credentials") - - # Clear all credentials (skip token revocation to avoid delays) + + # Populate the auth manager from os.environ (which + # was loaded from .env at module import) so the reset + # call has the credentials needed to revoke the + # refresh token at Dropbox. Without this, a leaked + # refresh_token + app_secret pair would still be valid + # at Dropbox until manually revoked. Revocation is + # best-effort with an 8-second timeout (see + # DropboxAuthManager.reset) β€” local cleanup proceeds + # regardless. + auth_manager.app_key = os.environ.get("DROPBOX_APP_KEY") or auth_manager.app_key + auth_manager.app_secret = os.environ.get("DROPBOX_APP_SECRET") or auth_manager.app_secret + auth_manager.refresh_token = os.environ.get("DROPBOX_REFRESH_TOKEN") or auth_manager.refresh_token + print("[DropSend Setup] Clearing all stored credentials") - auth_manager.reset(revoke_token=False) - + auth_manager.reset(revoke_token=True) + # Manually clear all credential files node_dir = os.path.dirname(__file__) - + # Clear .env file if it exists env_path = os.path.join(node_dir, ".env") if os.path.exists(env_path): print(f"[DropSend Setup] Removing .env file: {env_path}") os.remove(env_path) + + # Clear in-process os.environ for the credential keys. + # __init__.py's load_dotenv() copies .env values into + # os.environ at module import; deleting .env on disk + # does not unset those copies. Without this, the next + # Setup run hits the `env_vars_set` short-circuit at + # line ~165 and returns "credentials found in system + # environment variables" β€” even though the user is + # mid-reconfigure and expects to enter a new auth code. + # We don't touch DROPBOX_FOLDER (a path, not a credential) + # or COMFYUI_ENCRYPTION_KEY (kept across reconnects). + for key in ("DROPBOX_APP_KEY", "DROPBOX_APP_SECRET", "DROPBOX_REFRESH_TOKEN", "DROPBOX_ACCESS_TOKEN"): + if key in os.environ: + os.environ.pop(key, None) + print(f"[DropSend Setup] Cleared {key} from process environment") - # Send WebSocket message to trigger ComfyUI refresh after clearing credentials + # Send WebSocket notification to the originating browser + # so its auth fields refresh. Refuse to broadcast β€” if + # there's no client_id (e.g. CLI submission without one + # in the prompt JSON), every connected browser tab on + # this ComfyUI instance would otherwise refresh. try: from server import PromptServer - message_data = { - "type": "dropbox_reconnect_complete", - "success": True, - "message": "Credentials cleared - ComfyUI will refresh to show auth fields" - } - PromptServer.instance.send_sync("dropbox_reconnect_complete", message_data) - print(f"[DropSend Setup] Sent WebSocket notification for reconnect completion") + sid = getattr(PromptServer.instance, "client_id", None) + if not sid: + print( + "[DropSend Setup] Skipping reconnect-complete " + "broadcast: prompt has no client_id (CLI " + "submission?). Submit from the ComfyUI web UI " + "to receive the refresh notification." + ) + else: + message_data = { + "type": "dropbox_reconnect_complete", + "success": True, + "client_id": sid, + "message": "Credentials cleared - ComfyUI will refresh to show auth fields", + } + PromptServer.instance.send_sync( + "dropbox_reconnect_complete", message_data, sid + ) + print(f"[DropSend Setup] Sent WebSocket notification for reconnect completion") except Exception as e: print(f"[DropSend Setup] Warning: Could not send WebSocket notification: {e}") @@ -115,18 +445,27 @@ class DropSendSetupNode: if runpod_env_set: return ("Warning: Detected RunPod secrets. Using those instead.",) - # New setup flow using DropboxAuthManager + # New setup flow using DropboxAuthManager. + # + # Below this point the node accepts Dropbox app credentials from + # workflow inputs and persists them. On a remotely-reachable + # ComfyUI instance that flow is exploitable β€” a workflow author + # could swap in their own Dropbox app to redirect uploads. Gate + # it behind an explicit operator opt-in. + if not _setup_opt_in_enabled(): + message = _SETUP_OPT_IN_HELP + print(f"[DropSend Setup] {message}") + return {"ui": {"text": [message]}, "result": (message,)} + print(f"[DropSend Setup] Starting new setup flow") - + # Clean up the inputs first app_key_clean = app_key.strip() if app_key else "" app_secret_clean = app_secret.strip() if app_secret else "" auth_code_clean = auth_code.strip() if auth_code else "" - print(f"[DropSend Setup] Cleaned inputs:") - print(f" app_key_clean: '{app_key_clean}' (len: {len(app_key_clean)})") - print(f" app_secret_clean: '{app_secret_clean}' (len: {len(app_secret_clean)})") - print(f" auth_code_clean: '{auth_code_clean}' (len: {len(auth_code_clean)})") + # Log only the lengths, not the values themselves. + print(f"[DropSend Setup] Cleaned input lengths: app_key={len(app_key_clean)}, app_secret={len(app_secret_clean)}, auth_code={len(auth_code_clean)}") # Check if we have app credentials if not app_key_clean or not app_secret_clean: @@ -138,21 +477,59 @@ class DropSendSetupNode: if not auth_code_clean: print(f"[DropSend Setup] No auth code provided - generating OAuth URL for manual flow") auth_temp = DropboxAuthManager(app_key=app_key_clean) - + # Manual OAuth flow without redirect_uri (Dropbox will display the code) - oauth_url = auth_temp.get_oauth_url(require_reapprove=True) - + oauth_url = auth_temp.get_oauth_url(force_reapprove=True) + + # Print a high-visibility banner with the OAuth URL so the + # operator can see it directly in the ComfyUI terminal + # without needing to wire a Show Text node to the Setup + # Node's output. The URL contains only the OAuth client_id + # (Dropbox app_key) β€” semi-public per Dropbox's design, + # not a credential β€” so it's safe to print to console. + print() + print("=" * 80) + print("DROPSEND β€” DROPBOX AUTHORIZATION REQUIRED") + print("=" * 80) + print("Open this URL in your browser to authorize:") + print() + print(f" {oauth_url}") + print() + print("After authorizing, Dropbox will display an authorization code.") + print("Paste it into the credentials modal that auto-opened in your") + print("browser, then click Save and re-queue this workflow.") + print("=" * 80) + print() + + # Auto-open the credentials-entry modal in the originating + # browser so the user can paste the auth_code as soon as + # Dropbox shows it. Same client_id-only delivery pattern + # we use for the credentials_ready event β€” refuse if + # there's no client_id (CLI submission), otherwise the + # event would broadcast to every connected browser tab. try: - print(f"[DropSend Setup] Setting up manual OAuth popup...") - # JavaScript will automatically open a popup window - message = f"Dropbox OAuth Ready!\n\nClick the link below to authorize with Dropbox:\n\n{oauth_url}\n\nA popup window will open. After authorization, Dropbox will show your auth code.\nCopy the code and paste it into the 'auth_code' field above, then run this node again." - print(f"[DropSend Setup] OAuth URL ready for popup: {oauth_url}") + from server import PromptServer + sid = getattr(PromptServer.instance, "client_id", None) + if sid: + PromptServer.instance.send_sync( + "dropsend_credentials_needed", + {"client_id": sid, "stage": "auth_code"}, + sid, + ) + print("[DropSend Setup] Sent credentials-needed event to browser (auto-opens modal for auth_code paste)") except Exception as e: - print(f"[DropSend Setup] Error setting up OAuth: {e}") - message = f"Dropbox Authorization:\n\nPlease visit this URL to authorize:\n\n{oauth_url}\n\nAfter authorization, Dropbox will display your auth code. Copy it and paste into the 'auth_code' field above." - - print(f"[DropSend Setup] OAuth URL: {oauth_url}") - + print(f"[DropSend Setup] Warning: could not send credentials-needed event: {e}") + + message = ( + f"Dropbox OAuth Ready!\n\n" + f"Click the link below to authorize with Dropbox " + f"(also printed in the ComfyUI terminal):\n\n" + f"{oauth_url}\n\n" + f"A popup window will open. After authorization, Dropbox " + f"will show your auth code.\nCopy the code and paste it " + f"into the 'auth_code' field above, then run this node again." + ) + # Use ComfyUI's dynamic return format for better UI integration return { "ui": {"text": [message]}, @@ -176,70 +553,241 @@ class DropSendSetupNode: encryption_key = None if encryption_key_method != "off": encryption_key = Fernet.generate_key().decode() - - # Prepare .env lines - env_lines = [] + + # Route each credential to one of two destinations based on the + # user's two independent choices: + # + # storage_method = env_file | display_only + # encryption_key_method= save to .env | Display Only | off + # + # `env_file` / `save to .env` -> persist to .env on disk + # `display_only` / `Display Only` -> deliver to the originating + # browser via a WebSocket side-channel that does NOT pass + # through the node's `ui` or `result`. Both of those land in + # PromptServer.history (see ComfyUI execution.py β€” the + # history_result["outputs"] dict is built from ui_outputs), + # which is served on the unauthenticated /history endpoint + # and would expose any credential placed there. Cloud users + # (RunPod, Docker) explicitly choose `display_only` to keep + # credentials out of the pod's filesystem entirely. + env_writes = [] # (NAME, value) pairs to persist to .env + ws_payload = {} # NAME -> value, delivered via WebSocket only + + dropbox_creds = [ + ("DROPBOX_APP_KEY", app_key_clean), + ("DROPBOX_APP_SECRET", app_secret_clean), + ("DROPBOX_REFRESH_TOKEN", refresh_token), + ] if storage_method == "env_file": - env_lines.append(f"DROPBOX_APP_KEY={app_key_clean}") - env_lines.append(f"DROPBOX_APP_SECRET={app_secret_clean}") - env_lines.append(f"DROPBOX_REFRESH_TOKEN={refresh_token}") - env_lines.append(f"DROPBOX_FOLDER={dropbox_dest_folder}") + env_writes.extend(dropbox_creds) + else: + for name, value in dropbox_creds: + ws_payload[name] = value + if encryption_key_method == "save to .env" and encryption_key: - env_lines.append(f"COMFYUI_ENCRYPTION_KEY={encryption_key}") - - # Write to .env if there are lines to write - if env_lines: - node_dir = os.path.dirname(__file__) - env_path = os.path.join(node_dir, ".env") - with open(env_path, "w") as f: - f.write("\n".join(env_lines) + "\n") - - # Prepare display lines - display_lines = [] - if storage_method == "display_only": - display_lines.append(f"DROPBOX_APP_KEY={app_key_clean}") - display_lines.append(f"DROPBOX_APP_SECRET={app_secret_clean}") - display_lines.append(f"DROPBOX_REFRESH_TOKEN={refresh_token}") - display_lines.append(f"DROPBOX_FOLDER={dropbox_dest_folder}") - if encryption_key_method == "Display Only" and encryption_key: - display_lines.append(f"COMFYUI_ENCRYPTION_KEY={encryption_key}") - - # Build message - message = "" - if storage_method == "env_file": - message = "Dropbox connected successfully! Credentials saved to .env file." - elif storage_method == "display_only": - message = """Dropbox Connected Successfully! + env_writes.append(("COMFYUI_ENCRYPTION_KEY", encryption_key)) + elif encryption_key_method == "Display Only" and encryption_key: + ws_payload["COMFYUI_ENCRYPTION_KEY"] = encryption_key -===================================================================== -ENVIRONMENT VARIABLES - Copy & Paste Ready -===================================================================== + # Merge with any existing .env so re-running setup is non-destructive. + # + # - Non-setup keys (e.g. the AutoUploader's ENABLE_ENCRYPTION, + # POST_DELETE_ENC, etc.) survive a setup re-run unchanged. + # - Setup keys whose destination this run is NOT the .env file + # are dropped from disk: either they're being rewritten from + # env_writes, or they're being delivered via the browser-only + # WebSocket and the old on-disk copy is now stale. Switching + # from env_file -> display_only therefore actually scrubs the + # credential lines off disk, instead of leaving stale secrets + # in .env. + # - The race-free open (O_CREAT with mode 0o600) keeps the file + # from being world/group-readable in the open->chmod window. + # Keys the Setup Node "owns" β€” these get scrubbed from .env + # on a setup re-run that's not writing them back, so old + # values don't linger when switching env_file -> display_only. + # DROPBOX_FOLDER is intentionally absent: it's owned by the + # AutoUploader, not Setup, and would otherwise get wiped + # whenever the user re-runs Setup to rotate credentials. + SETUP_KEYS = { + "DROPBOX_APP_KEY", + "DROPBOX_APP_SECRET", + "DROPBOX_REFRESH_TOKEN", + "COMFYUI_ENCRYPTION_KEY", + } -""" + "\n".join(display_lines) + """ + node_dir = os.path.dirname(__file__) + env_path = os.path.join(node_dir, ".env") + + existing = {} + if os.path.exists(env_path): + existing = dict(dotenv_values(env_path)) + + preserved = {k: v for k, v in existing.items() if k not in SETUP_KEYS} + merged = dict(preserved) + for name, value in env_writes: + merged[name] = value + + if merged: + fd = os.open(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") + if env_writes: + # Load the freshly-written values into os.environ so + # they are usable by the AutoUploader (or any other + # node) in the same ComfyUI session, without requiring + # a restart. + load_dotenv(env_path, override=True) + # If env_writes is empty (e.g. display_only re-run that + # only scrubbed credentials), don't load β€” the new file + # has no credentials we'd want to push into os.environ. + elif os.path.exists(env_path): + # No setup keys this run AND nothing else worth keeping β€” + # remove the empty-shell file rather than leave a 0-byte + # 0600 file behind. + os.remove(env_path) + + # Deliver the browser-only payload via PromptServer's WebSocket + # to the originating client. This data is NOT included in the + # node's `ui` or `result` and is therefore never persisted in + # PromptServer.history. + # + # Security: refuse to send if the prompt has no client_id. + # ComfyUI's send_sync with sid=None broadcasts to *every* + # connected WebSocket client (server.py send_json: when sid + # is None it iterates self.sockets). On a network-reachable + # host (RunPod tunnel, LAN, etc.) that means a CLI/curl/SDK + # submission without a client_id field would push the + # credentials to any browser tab pointed at the same ComfyUI + # instance. So we treat "no client_id" as a delivery failure + # and surface it to the operator instead. + ws_delivered = False + ws_delivery_error = None + if ws_payload: + try: + from server import PromptServer + sid = getattr(PromptServer.instance, "client_id", None) + if not 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." + ) + msg = ( + "[DropSend 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." + ) + print(msg) + try: + import logging as _logging + _logging.getLogger(__name__).warning(msg) + except Exception: + pass + else: + # Echo the sid inside the payload so the JS handler + # can defense-in-depth verify it matches api.clientId + # and ignore mismatched events. + PromptServer.instance.send_sync( + "dropsend_credentials_ready", + {"credentials": dict(ws_payload), "client_id": sid}, + sid, + ) + ws_delivered = True + except Exception as e: + ws_delivery_error = str(e) + print(f"[DropSend Setup] WebSocket delivery failed: {e}") + + # Build the result/ui message β€” contains NO credential values. + # Both `ui` and `result` are persisted in PromptServer.history. + # Keep it short and action-oriented: tell the user the one + # thing to do next. + message_parts = ["Dropbox connected."] + if ws_payload and ws_delivered: + # display_only path (covers the mixed case where env_writes + # also happened β€” the panel still has values the user must + # manually copy into platform secrets before they're useful). + # Two-step framing: most cloud platforms (RunPod, Docker, + # K8s) require a container/pod restart for newly-added + # secrets to take effect. So "copy then run" is wrong; + # the correct flow is "copy, then configure secrets, then + # restart the pod, then on the new pod remove the Setup + # node and run AutoUploader". + message_parts.append( + "Next steps:\n" + " 1. Copy each value from the DropSend 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 'DropSend - AutoUploader' " + "node." + ) + elif ws_payload and not ws_delivered: + # Failure-mode message. The two audiences and what each + # should do: + # β€’ Cloud users (RunPod, hosted) using display_only β€” + # must submit from the web UI so a client_id is on + # the prompt. They should NOT switch to env_file + # because that puts secrets on the pod's filesystem, + # which is exactly what display_only is designed to + # avoid. + # β€’ Local users β€” env_file is a reasonable fallback. + message_parts.append( + f"Browser delivery refused: {ws_delivery_error}\n\n" + f"What to do:\n" + f" β€’ Cloud / RunPod / hosted ComfyUI: Submit this " + f"workflow from your web browser (not via curl / API / " + f"SDK). The browser's WebSocket connection attaches a " + f"client_id automatically. display_only never writes " + f"credentials to the pod's filesystem.\n" + f" β€’ Local install only: If you're running ComfyUI on " + f"the same machine you're using and don't mind " + f"credentials in a 0600 .env file on disk, switch " + f"storage_method to 'env_file' and rerun." + ) + elif env_writes: + # env_file path: creds are already loaded into the running + # process; user just needs to remove the Setup node. + message_parts.append( + "You can remove this Setup node from your workflow " + "and run the 'DropSend - AutoUploader' node." + ) + elif existing and any(k in SETUP_KEYS for k in existing): + # Re-run that scrubbed previously-written credential lines + # from .env (typical when switching env_file -> display_only). + message_parts.append( + "Previous credentials in .env were cleared because " + "this run delivers them another way." + ) + + message = "\n\n".join(message_parts) + + # Console banner β€” confirmation only, values redacted. The + # operator who controls the host can verify what was set and + # where, without secrets going to captured stdout. + print("\n" + "=" * 80) + print("DROPSEND 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:" if ws_delivered else "FAILED to send to browser:" + print(label) + for name, value in ws_payload.items(): + print(" " + _redact_kv_line(f"{name}={value}")) + print("=" * 80 + "\n") -===================================================================== -Perfect for RunPod, Docker, and production environments! -These credentials are ready to use immediately. -=====================================================================""" - - # For display_only, ensure credentials are prominently shown in console - if display_lines: - print("\n" + "="*80) - print("DROPBOX CREDENTIALS READY FOR PRODUCTION - COPY FROM CONSOLE") - print("="*80) - for line in display_lines: - print(line) - print("="*80) - print("Copy the lines above to your environment variables!") - print("Perfect for RunPod, Docker, and production environments!") - print("="*80 + "\n") - print(f"[DropSend Setup] {message}") - - # Use ComfyUI's dynamic return format for better UI integration + return { "ui": {"text": [message]}, - "result": (message,) + "result": (message,), } except Exception as e: diff --git a/dropsend_uploader_node.py b/dropsend_uploader_node.py index 9487d3a..e325466 100644 --- a/dropsend_uploader_node.py +++ b/dropsend_uploader_node.py @@ -4,19 +4,58 @@ import os import threading import logging import time +from datetime import date, datetime from dotenv import load_dotenv, dotenv_values from watchdog.observers import Observer from .monitor_output import start_monitoring, watcher_observer, stop_queue_processor from .encrypt_file import FileEncryptHandler, ENCRYPT_EXTENSIONS, stop_queue_processor as stop_encrypt_queue_processor, get_encryption_key +from .safe_paths import resolve_safe_watch_folder, validate_dropbox_dest, get_output_root -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('dropsend.log'), - logging.StreamHandler() - ] -) +# Folder format options. The first option uses the user's literal +# text as a project folder name (no date appended); the remaining +# options append today's date to the user's base folder in the +# selected style. Date formats use "-" or "." as separators rather +# than "/" so the date stays within a single path segment, which +# keeps _strip_trailing_date simple and avoids visually splitting +# the date across folders. +PROJECT_NAME_FORMAT = "project_name" +DATE_FORMATS = { + "mm-dd-yyyy": "%m-%d-%Y", + "dd-mm-yyyy": "%d-%m-%Y", + "yyyy-mm-dd": "%Y-%m-%d", + "dd.mm.yyyy": "%d.%m.%Y", + "yyyy.mm.dd": "%Y.%m.%d", +} +FOLDER_FORMAT_OPTIONS = [PROJECT_NAME_FORMAT] + list(DATE_FORMATS.keys()) +DEFAULT_FOLDER_FORMAT = PROJECT_NAME_FORMAT + + +def _today_str(format_name): + fmt = DATE_FORMATS.get(format_name) + if not fmt: + return "" + return date.today().strftime(fmt) + + +def _strip_trailing_date(path, format_name): + """If the last `/`-separated segment of `path` parses as a date in + the given format, return `path` with that segment removed. Otherwise + return `path` unchanged. Used to recover the BASE folder from a + persisted destination that already has a date appended.""" + fmt = DATE_FORMATS.get(format_name) + if not fmt: + return path + cleaned = path.rstrip("/") + parts = cleaned.split("/") + if len(parts) < 2: + return path + try: + datetime.strptime(parts[-1], fmt) + except ValueError: + return path + return "/".join(parts[:-1]) or "/" + +# Logging configured by the package __init__ β€” see __init__.py. logger = logging.getLogger(__name__) # Define encrypt_observer at module level @@ -28,25 +67,49 @@ class DropSendAutoUploaderNode: node_dir = os.path.dirname(__file__) env_path = os.path.join(node_dir, ".env") - default_watch = os.path.join(os.getcwd(), "output") - default_dest = "/Apps/ComfyUI_Output_Files" + default_watch = get_output_root() + default_dated_base = "/ComfyUI_Output_Files" + default_flat_base = "/ComfyUI-Project/My-Project" default_encrypt = True default_delete_enc = False default_subfolder_monitor = True default_run_process = True + default_folder_format = DEFAULT_FOLDER_FORMAT if os.path.exists(env_path): cfg = dotenv_values(env_path) - default_dest = cfg.get("DROPBOX_FOLDER", default_dest) + # Two persisted bases let the dated and project-name modes + # each remember their own folder pattern, so switching the + # dropdown doesn't lose whichever setup the user isn't + # currently using. Legacy fallback chain on the dated key + # picks up destinations configured by the Setup Node or a + # prior version of this node. + default_dated_base = cfg.get( + "DROPBOX_FOLDER_BASE_DATED", + cfg.get("DROPBOX_FOLDER_BASE", cfg.get("DROPBOX_FOLDER", default_dated_base)), + ) + default_flat_base = cfg.get("DROPBOX_FOLDER_BASE_FLAT", default_flat_base) default_encrypt = cfg.get("ENABLE_ENCRYPTION", "False").lower() == "true" default_delete_enc = cfg.get("POST_DELETE_ENC", "False").lower() == "true" default_subfolder_monitor = cfg.get("SUBFOLDER_MONITOR", "True").lower() == "true" default_run_process = cfg.get("RUN_PROCESS", "True").lower() == "true" + default_folder_format = cfg.get("DROPBOX_FOLDER_FORMAT", default_folder_format) + if default_folder_format not in FOLDER_FORMAT_OPTIONS: + default_folder_format = DEFAULT_FOLDER_FORMAT + + if default_folder_format == PROJECT_NAME_FORMAT: + default_dest = default_flat_base + else: + # Defensive: strip a trailing date in case a legacy + # DROPBOX_FOLDER value already had one appended. + default_dated_base = _strip_trailing_date(default_dated_base, default_folder_format) + default_dest = default_dated_base.rstrip("/") + "/" + _today_str(default_folder_format) return { "required": { "watch_folder": ("STRING", {"default": default_watch}), "dropbox_dest_folder": ("STRING", {"default": default_dest}), + "folder_format": (FOLDER_FORMAT_OPTIONS, {"default": default_folder_format}), "enable_encryption": ("BOOLEAN", {"default": default_encrypt}), "Post_Delete_Enc": ("BOOLEAN", {"default": default_delete_enc}), "Subfolder_Monitor": ("BOOLEAN", {"default": default_subfolder_monitor}), @@ -58,9 +121,25 @@ class DropSendAutoUploaderNode: OUTPUT_NODE = True FUNCTION = "start" - def start(self, watch_folder, dropbox_dest_folder, enable_encryption, Post_Delete_Enc, Subfolder_Monitor, run_process): + def start(self, watch_folder, dropbox_dest_folder, folder_format, enable_encryption, Post_Delete_Enc, Subfolder_Monitor, run_process): global encrypt_observer - logger.info(f"Starting DropSend AutoUploader: watch_folder={watch_folder}, dropbox_dest_folder={dropbox_dest_folder}, encryption={enable_encryption}, Post_Delete_Enc={Post_Delete_Enc}, Subfolder_Monitor={Subfolder_Monitor}, run_process={run_process}") + + # Resolve the actual destination at run-time. For date formats + # this refreshes the date on every workflow run, so a saved + # workflow opened yesterday still uploads into today's folder. + # For project_name we use the user's literal text verbatim and + # persist it as-is. + if folder_format not in FOLDER_FORMAT_OPTIONS: + folder_format = DEFAULT_FOLDER_FORMAT + if folder_format == PROJECT_NAME_FORMAT: + dated_base = None + flat_base = dropbox_dest_folder + else: + dated_base = _strip_trailing_date(dropbox_dest_folder, folder_format) + dropbox_dest_folder = dated_base.rstrip("/") + "/" + _today_str(folder_format) + flat_base = None + + logger.info(f"Starting DropSend AutoUploader: watch_folder={watch_folder}, dropbox_dest_folder={dropbox_dest_folder}, folder_format={folder_format}, encryption={enable_encryption}, Post_Delete_Enc={Post_Delete_Enc}, Subfolder_Monitor={Subfolder_Monitor}, run_process={run_process}") # Handle stopping the process if run_process is False if not run_process: @@ -89,14 +168,26 @@ Set 'run_process' to True and run the node again to resume. logger.info(stop_message) return (f"All monitoring, uploading, and encryption stopped for {watch_folder}",) - # Validate watch_folder - if not os.path.exists(watch_folder): - logger.error(f"Watch folder does not exist: {watch_folder}") - raise ValueError(f"Watch folder does not exist: {watch_folder}") + # Constrain watch_folder to the ComfyUI output directory tree. The + # uploader runs a recursive Watchdog observer that uploads every file + # it sees, so an unrestricted path turns this node into an + # arbitrary-file-read primitive on remotely-accessible ComfyUI hosts. + try: + watch_folder = resolve_safe_watch_folder(watch_folder) + except ValueError as e: + logger.error(str(e)) + raise + if not os.path.isdir(watch_folder): logger.error(f"Watch folder is not a directory: {watch_folder}") raise ValueError(f"Watch folder is not a directory: {watch_folder}") + try: + dropbox_dest_folder = validate_dropbox_dest(dropbox_dest_folder) + except ValueError as e: + logger.error(str(e)) + raise + # Validate Dropbox credentials from .dropbox_upload import get_token try: @@ -120,12 +211,28 @@ Set 'run_process' to True and run the node again to resume. if os.path.exists(env_path): existing = dotenv_values(env_path) + # DROPBOX_FOLDER is the resolved destination (with date when a + # date format is selected) for visibility and for any other + # tool reading the .env. The two _BASE keys are written + # independently β€” only the one matching the active format is + # touched, so the other mode's saved pattern survives a switch. existing["DROPBOX_FOLDER"] = dropbox_dest_folder + if dated_base is not None: + existing["DROPBOX_FOLDER_BASE_DATED"] = dated_base + if flat_base is not None: + existing["DROPBOX_FOLDER_BASE_FLAT"] = flat_base + existing["DROPBOX_FOLDER_FORMAT"] = folder_format existing["ENABLE_ENCRYPTION"] = str(enable_encryption) existing["POST_DELETE_ENC"] = str(Post_Delete_Enc) existing["SUBFOLDER_MONITOR"] = str(Subfolder_Monitor) existing["RUN_PROCESS"] = str(run_process) - with open(env_path, "w") as f: + # Race-free open: the file is created with mode 0600 from the + # start. The merged file may include DROPBOX_REFRESH_TOKEN / + # COMFYUI_ENCRYPTION_KEY the user previously stored, so the + # window between create-with-default-perms and a follow-up + # chmod is exactly what we need to avoid. + fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: for k, v in existing.items(): f.write(f"{k}={v}\n") @@ -133,35 +240,78 @@ Set 'run_process' to True and run the node again to resume. # Start processes only if run_process is True if run_process: - # Start folder watcher - thread = threading.Thread( - target=start_monitoring, - args=(watch_folder, dropbox_dest_folder, enable_encryption, Post_Delete_Enc, Subfolder_Monitor), - daemon=True - ) - thread.start() - - # Start encryption watcher if enabled + # Build the encrypt handler up-front; start_monitoring will + # schedule it on the SAME Observer as the upload handler. + # macOS's _fsevents library refuses two separate watches on + # the same path (the second emitter dies with "already + # scheduled" and never delivers events), so we share one + # Observer with multiple handlers. + encrypt_handler = None if enable_encryption: encrypt_handler = FileEncryptHandler(watch_folder, False, Subfolder_Monitor) - encrypt_observer = Observer() - encrypt_observer.schedule(encrypt_handler, watch_folder, recursive=Subfolder_Monitor) - encrypt_observer.start() + + # Run synchronously β€” Observer manages its own threads, so + # there is nothing to gain from wrapping this in a thread, + # and a thread introduces a startup race where the upload + # observer might not be ready before the first file lands. + start_monitoring( + watch_folder, + dropbox_dest_folder, + enable_encryption, + Post_Delete_Enc, + Subfolder_Monitor, + encrypt_handler=encrypt_handler, + ) + + if encrypt_handler is not None: logger.info(f"Starting encryption monitor for {watch_folder}") - def keep_encrypt_alive(): - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - if encrypt_observer: - encrypt_observer.stop() - if encrypt_observer: - encrypt_observer.join() + # Build a banner the user sees in the node's UI output and in + # the ComfyUI console. Each setting line is `key = value + # (plain-English meaning)` so the implications are obvious at + # a glance β€” no jumping back to the README to figure out what + # POST_DELETE_ENC does. + sub_desc = "subfolders watched recursively" if Subfolder_Monitor else "only the top-level folder is watched" + if folder_format == PROJECT_NAME_FORMAT: + fmt_desc = "your dropbox_dest_folder is used verbatim, e.g. /ComfyUI-Project/My-Project" + else: + fmt_desc = f"today's date in {folder_format} appended daily, folder refreshes each session" + enc_desc = "files encrypted (.enc) before upload" if enable_encryption else "files uploaded as-is, no encryption" + if enable_encryption: + del_desc = ".enc files deleted after upload verifies" if Post_Delete_Enc else ".enc files kept on disk after upload" + else: + del_desc = "ignored β€” only matters when enable_encryption is true" - threading.Thread(target=keep_encrypt_alive, daemon=True).start() + banner = f""" +===================================================================== +πŸ“¦πŸ“€ DropSend - AutoUploader - RUNNING +===================================================================== - return (f"πŸ“¦βœ… .env updated: DROPBOX_FOLDER={dropbox_dest_folder}, ENABLE_ENCRYPTION={enable_encryption}, POST_DELETE_ENC={Post_Delete_Enc}, SUBFOLDER_MONITOR={Subfolder_Monitor}, RUN_PROCESS={run_process}",) +Watching: + {watch_folder} + +Uploading to: + {dropbox_dest_folder} + +Settings: + enable_encryption = {str(enable_encryption).lower()} ({enc_desc}) + folder_format = {folder_format} ({fmt_desc}) + Post_Delete_Enc = {str(Post_Delete_Enc).lower()} ({del_desc}) + Subfolder_Monitor = {str(Subfolder_Monitor).lower()} ({sub_desc}) + run_process = {str(run_process).lower()} (monitoring active β€” set false and re-run to stop) + +How to change settings while running: + β€’ Edit any field on this node and re-run the workflow to apply. + β€’ To switch from a date format to a project name: pick `project_name` + in folder_format, edit dropbox_dest_folder to your project name, + then re-run. + β€’ To stop monitoring entirely: set run_process = false and re-run. + +===================================================================== +""" + print(banner) + logger.info(banner) + return (banner,) # Required mappings for ComfyUI NODE_CLASS_MAPPINGS = {"DropSendAutoUploader": DropSendAutoUploaderNode} diff --git a/encrypt_file.py b/encrypt_file.py index 446f4a2..afaecf4 100644 --- a/encrypt_file.py +++ b/encrypt_file.py @@ -7,15 +7,9 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from cryptography.fernet import Fernet import threading +from .safe_paths import is_safe_event_path -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('dropsend.log'), - logging.StreamHandler() - ] -) +# Logging configured by the package __init__ β€” see __init__.py. logger = logging.getLogger(__name__) ENCRYPT_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.avi', '.mov') @@ -62,11 +56,25 @@ class FileEncryptHandler(FileSystemEventHandler): self.start_queue_processor() def on_created(self, event): - if not event.is_directory and event.src_path.lower().endswith(ENCRYPT_EXTENSIONS): + if event.is_directory: + return + # Defense-in-depth: refuse symlinks or paths whose realpath escapes + # the allowed output roots, so a symlink in the watched directory + # cannot trick the encryptor into reading and uploading a file + # outside the tree. + if not is_safe_event_path(event.src_path): + logger.warning(f"Skipping unsafe path (symlink or outside output root): {event.src_path}") + return + if event.src_path.lower().endswith(ENCRYPT_EXTENSIONS): logger.info(f"Detected new file to encrypt: {event.src_path}") self.file_queue.put(event.src_path) def start_queue_processor(self): + global _stop_queue_processor + # Reset the stop flag so that a new handler created after a prior + # stop_queue_processor() call still has a working queue worker. + # Mirrors what start_monitoring() does on the upload side. + _stop_queue_processor = False def process_queue(): while not _stop_queue_processor: if not self.file_queue.empty(): @@ -94,7 +102,7 @@ class FileEncryptHandler(FileSystemEventHandler): enc_path = file_path + '.enc' with open(enc_path, 'wb') as f: f.write(encrypted_data) - logger.info(f"Created encrypted file: {enc_path}") + logger.info(f"πŸ“¦πŸ” Encrypted: {enc_path}") # Defer deletion until after upload verification (handled in monitor_output.py) except Exception as e: diff --git a/monitor_output.py b/monitor_output.py index 9c27f93..d2ade0b 100644 --- a/monitor_output.py +++ b/monitor_output.py @@ -9,15 +9,12 @@ from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from .dropbox_upload import upload_to_dropbox from .encrypt_file import ENCRYPT_EXTENSIONS +from .safe_paths import is_safe_event_path -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('dropsend.log'), - logging.StreamHandler() - ] -) +# Logging is configured by the package __init__ β€” see __init__.py for +# the FileHandler attached to the root logger. logging.getLogger here +# returns a child logger that propagates to root and inherits its +# handlers (file + ComfyUI's own stdout). logger = logging.getLogger(__name__) watcher_observer = None @@ -66,6 +63,14 @@ class NewFileHandler(FileSystemEventHandler): if event.is_directory: return file_path = event.src_path + # Defense-in-depth: refuse symlinks or paths whose realpath escapes + # the allowed output roots. The watch_folder input is already + # clamped, but Watchdog follows symlinks placed inside the watched + # tree, so without this check a `output/leak.png -> ~/.ssh/id_rsa` + # would still get uploaded. + if not is_safe_event_path(file_path): + logger.warning(f"Skipping unsafe path (symlink or outside output root): {file_path}") + return if encryption_enabled and not file_path.lower().endswith('.enc'): logger.info(f"Skipping non-.enc file (encryption enabled): {file_path}") return @@ -94,7 +99,7 @@ class NewFileHandler(FileSystemEventHandler): threading.Thread(target=process_queue, daemon=True).start() -def start_monitoring(watch_folder, dropbox_dest_folder, enable_encryption=False, delete_enc=False, subfolder_monitor=True): +def start_monitoring(watch_folder, dropbox_dest_folder, enable_encryption=False, delete_enc=False, subfolder_monitor=True, encrypt_handler=None): global watcher_observer, watcher_handler, encryption_enabled, _stop_queue_processor encryption_enabled = enable_encryption _stop_queue_processor = False # Reset stop signal when starting @@ -107,9 +112,18 @@ def start_monitoring(watch_folder, dropbox_dest_folder, enable_encryption=False, watcher_handler = NewFileHandler(dropbox_dest_folder, delete_enc) watcher_observer = Observer() watcher_observer.schedule(watcher_handler, watch_folder, recursive=subfolder_monitor) + # macOS's _fsevents C extension maintains a global registry of watched + # paths and refuses a second watch on the same path with a + # "Cannot add watch ... it is already scheduled" RuntimeError that + # crashes the second Observer's emitter thread silently. So when + # encryption is enabled, register the encrypt handler on the SAME + # Observer (watchdog supports multiple handlers per observer and + # dispatches every event to every handler). + if encrypt_handler is not None: + watcher_observer.schedule(encrypt_handler, watch_folder, recursive=subfolder_monitor) watcher_observer.start() - logger.info(f"Now watching: {watch_folder} (Subfolder_Monitor: {'Yes' if subfolder_monitor else 'No'})") + logger.info(f"πŸ“¦πŸ‘€ Watching: {watch_folder} (Subfolder_Monitor: {'Yes' if subfolder_monitor else 'No'})") logger.info(f"Upload target: {dropbox_dest_folder}") logger.info(f"Encryption enabled: {'Yes' if encryption_enabled else 'No'}") diff --git a/oauth_handler.py b/oauth_handler.py deleted file mode 100644 index ded42c1..0000000 --- a/oauth_handler.py +++ /dev/null @@ -1,766 +0,0 @@ -# oauth_handler.py - OAuth callback handling for DropSendNode - -import asyncio -from aiohttp import web -from .dropbox_auth_manager import DropboxAuthManager - -# Global state to track pending OAuth flows -pending_oauth_sessions = {} - -class OAuthCallbackHandler: - def __init__(self): - self.session_id = None - self.app_key = None - self.app_secret = None - self.completion_callback = None - - def start_oauth_session(self, session_id, app_key, app_secret, completion_callback=None, storage_method="keyring", dropbox_folder="/Apps/ComfyUI_Output_Files", original_redirect_uri=None): - """Start a new OAuth session and store the credentials for callback processing""" - print(f"[OAuthHandler] Starting OAuth session: {session_id}") - - self.session_id = session_id - self.app_key = app_key - self.app_secret = app_secret - self.completion_callback = completion_callback - self.storage_method = storage_method - self.dropbox_folder = dropbox_folder - - # Store in global pending sessions - pending_oauth_sessions[session_id] = { - 'app_key': app_key, - 'app_secret': app_secret, - 'completion_callback': completion_callback, - 'storage_method': storage_method, - 'dropbox_folder': dropbox_folder, - 'original_redirect_uri': original_redirect_uri, # Store the original redirect URI - 'handler': self - } - - print(f"[OAuthHandler] OAuth session stored for: {session_id} with storage method: {storage_method}") - - async def process_callback(self, auth_code, redirect_uri=None): - """Process the OAuth callback with the received auth code""" - try: - print(f"[OAuthHandler] Processing callback for session: {self.session_id}") - print(f"[OAuthHandler] Auth code received: {auth_code[:10]}...") - print(f"[OAuthHandler] Processing callback for auth code exchange") - - # Use DropboxAuthManager to exchange the code - print(f"[OAuthHandler] Creating DropboxAuthManager for token exchange...") - auth_manager = DropboxAuthManager(self.app_key, self.app_secret) - print(f"[OAuthHandler] Exchanging auth code for tokens...") - result = auth_manager.exchange_auth_code_raw(auth_code, redirect_uri=redirect_uri) - refresh_token = result.get("refresh_token") - - print(f"[OAuthHandler] Auth code exchange successful - got refresh token: {bool(refresh_token)}") - print(f"[OAuthHandler] Using storage method: {self.storage_method}") - - # Handle different storage methods - success_message = None - if self.storage_method == "env_file": - # Store in .env file - import os - node_dir = os.path.dirname(__file__) - env_path = os.path.join(node_dir, ".env") - with open(env_path, "w") as f: - f.write(f"DROPBOX_APP_KEY={self.app_key}\n") - f.write(f"DROPBOX_APP_SECRET={self.app_secret}\n") - f.write(f"DROPBOX_REFRESH_TOKEN={refresh_token}\n") - f.write(f"DROPBOX_FOLDER={self.dropbox_folder}\n") - success_message = "Dropbox connected successfully! Credentials saved to .env file." - - elif self.storage_method == "display_only": - # Display credentials for manual copying - import os - - # Print credentials to console for easy copy/paste - print("\n" + "=" * 80) - print("DROPBOX CREDENTIALS READY FOR PRODUCTION - COPY FROM CONSOLE") - print("=" * 80) - print(f"DROPBOX_APP_KEY={self.app_key}") - print(f"DROPBOX_APP_SECRET={self.app_secret}") - print(f"DROPBOX_REFRESH_TOKEN={refresh_token}") - print(f"DROPBOX_FOLDER={self.dropbox_folder}") - print("=" * 80) - print("Copy the lines above to your environment variables!") - print("Perfect for RunPod, Docker, and production environments!") - print("=" * 80 + "\n") - - success_message = f"""Dropbox Connected Successfully! - -===================================================================== -ENVIRONMENT VARIABLES - Copy & Paste Ready -===================================================================== - -DROPBOX_APP_KEY={self.app_key} - -DROPBOX_APP_SECRET={self.app_secret} - -DROPBOX_REFRESH_TOKEN={refresh_token} - -DROPBOX_FOLDER={self.dropbox_folder} - -===================================================================== -Perfect for RunPod, Docker, and production environments! -These credentials are ready to use immediately. -=====================================================================""" - - else: - # Fallback to env_file - import os - node_dir = os.path.dirname(__file__) - env_path = os.path.join(node_dir, ".env") - with open(env_path, "w") as f: - f.write(f"DROPBOX_APP_KEY={self.app_key}\n") - f.write(f"DROPBOX_APP_SECRET={self.app_secret}\n") - f.write(f"DROPBOX_REFRESH_TOKEN={refresh_token}\n") - f.write(f"DROPBOX_FOLDER={self.dropbox_folder}\n") - success_message = "Dropbox connected successfully! Credentials saved to .env file." - - # Send WebSocket message to trigger ComfyUI refresh (except for display_only) - if self.storage_method != "display_only": - try: - from server import PromptServer - message_data = { - "type": "dropbox_oauth_complete", - "session_id": self.session_id, - "success": True, - "message": success_message - } - PromptServer.instance.send_sync("dropbox_oauth_complete", message_data) - print(f"[OAuthHandler] Sent WebSocket notification for OAuth completion") - except Exception as e: - print(f"[OAuthHandler] Warning: Could not send WebSocket notification: {e}") - else: - print(f"[OAuthHandler] Skipping WebSocket notification for display_only method") - - # Call completion callback if provided - if self.completion_callback: - await self.completion_callback(True, success_message) - - return True, success_message - - except Exception as e: - error_msg = f"Error: OAuth callback failed: {e}" - print(f"[OAuthHandler] ERROR: {error_msg}") - - if self.completion_callback: - await self.completion_callback(False, error_msg) - - return False, error_msg - -def create_display_only_success_page(session_id, app_key, app_secret, refresh_token, dropbox_folder): - """Create a success page with textarea fields for display_only storage method""" - from aiohttp import web - - return web.Response(text=f""" - - - - - Dropbox Authorization Successful - - - -
-
βœ…
-

Dropbox Connected Successfully!

-

Your credentials are ready for production use. Copy them to your environment variables.

- -

πŸ” Environment Variables

- -
- - - -
- -
- - - -
- -
- - - -
- -
- - - -
- -
-

πŸ“‹ How to Use These Credentials

-
    -
  1. Copy each environment variable above
  2. -
  3. Add them to your production environment (RunPod, Docker, etc.)
  4. -
  5. The ComfyUI DropSend node will detect and use them automatically
  6. -
  7. No additional setup required!
  8. -
-
- -
- Copy the credentials above, then close this window when done. -
-
- - - - - """, content_type='text/html') - -def get_server_base_url(request=None): - """Dynamically detect the server base URL for OAuth callbacks""" - import os - import socket - - try: - if request: - # Try to get from the incoming request - scheme = 'https' if request.secure else 'http' - host = request.host - base_url = f"{scheme}://{host}" - print(f"[OAuthHandler] Detected server URL from request: {base_url}") - return base_url - else: - # Fallback: try to detect from ComfyUI server instance - from server import PromptServer - if hasattr(PromptServer, 'instance') and PromptServer.instance: - # Get the port from ComfyUI server - port = getattr(PromptServer.instance, 'port', 8188) - - # Try to detect if we're on RunPod or similar cloud service - hostname = socket.gethostname() - print(f"[OAuthHandler] Detected hostname: {hostname}") - - # Check for RunPod-specific hostname patterns - if 'runpod' in hostname.lower() or hostname.endswith('.runpod.net'): - # For RunPod, try to get the public URL - runpod_public_ip = os.getenv('RUNPOD_PUBLIC_IP') - if runpod_public_ip: - base_url = f"https://{runpod_public_ip}:{port}" - print(f"[OAuthHandler] Detected RunPod URL: {base_url}") - return base_url - - # Check for common cloud service environment variables - public_url = os.getenv('COMFYUI_PUBLIC_URL') or os.getenv('PUBLIC_URL') - if public_url: - base_url = public_url.rstrip('/') - print(f"[OAuthHandler] Using configured PUBLIC_URL: {base_url}") - return base_url - - # Default fallback to localhost - base_url = f"http://localhost:{port}" - print(f"[OAuthHandler] Fallback to localhost: {base_url}") - return base_url - - except Exception as e: - print(f"[OAuthHandler] Error detecting server URL: {e}") - import traceback - traceback.print_exc() - - # Final fallback - base_url = "http://localhost:8188" - print(f"[OAuthHandler] Using final fallback: {base_url}") - return base_url - -# OAuth callback route handler -async def handle_oauth_callback(request): - """Handle incoming OAuth callback from Dropbox""" - try: - # Extract parameters from callback - auth_code = request.query.get('code') - error = request.query.get('error') - session_id = request.query.get('state') # We'll use state parameter to track sessions - - print(f"[OAuthCallback] Received callback - code: {bool(auth_code)}, error: {error}, state: {session_id}") - - if error: - error_msg = f"OAuth authorization failed: {error}" - print(f"[OAuthCallback] ERROR: {error_msg}") - return web.Response(text=f""" - -

❌ Authorization Failed

-

{error_msg}

-

You can close this window and try again in ComfyUI.

- - """, content_type='text/html', status=400) - - if not auth_code: - print(f"[OAuthCallback] ERROR: No authorization code received") - return web.Response(text=""" - -

❌ No Authorization Code

-

No authorization code was received. Please try again.

-

You can close this window and try again in ComfyUI.

- - """, content_type='text/html', status=400) - - # Find the pending OAuth session - if session_id and session_id in pending_oauth_sessions: - session_data = pending_oauth_sessions[session_id] - handler = session_data['handler'] - - # Use the original redirect URI that was used for authorization (not the detected one) - original_callback_url = session_data.get('original_redirect_uri') - if original_callback_url: - print(f"[OAuthCallback] Using stored redirect URI: {original_callback_url}") - callback_url = original_callback_url - else: - # Fallback to dynamic detection - base_url = get_server_base_url(request) - callback_url = f"{base_url}/oauth/dropbox/callback" - print(f"[OAuthCallback] Using detected redirect URI: {callback_url}") - - success, message = await handler.process_callback(auth_code, redirect_uri=callback_url) - - # Clean up the session - del pending_oauth_sessions[session_id] - - if success: - # Check if this is a display_only flow and show appropriate success page - storage_method = session_data.get('storage_method', 'keyring') - - if storage_method == 'display_only': - # Extract refresh token from the success message (token exchange already happened in process_callback) - if 'DROPBOX_REFRESH_TOKEN=' in message: - import re - refresh_token_match = re.search(r'DROPBOX_REFRESH_TOKEN=([^\s\n]+)', message) - if refresh_token_match: - refresh_token = refresh_token_match.group(1) - - app_key = session_data['app_key'] - app_secret = session_data['app_secret'] - dropbox_folder = session_data['dropbox_folder'] - - # Special success page with textarea fields for credentials (stays open for copying) - return create_display_only_success_page(session_id, app_key, app_secret, refresh_token, dropbox_folder) - - # If we couldn't extract the refresh token, fall back to error handling - return web.Response(text=f""" - -

❌ Display Setup Error

-

Could not extract refresh token from OAuth response.

-

Please close this window and try again in ComfyUI.

-

Debug info: {message[:200]}...

- - """, content_type='text/html', status=500) - else: - # Standard success page for env_file storage method (auto-closes quickly) - return web.Response(text=f""" - - - - - Dropbox Authorization Successful - - - -
-
βœ“
-

Authorization Successful!

-

{message}

- -
- - - """, content_type='text/html') - else: - return web.Response(text=f""" - - - - - Dropbox Authorization Failed - - - -
-
❌
-

Setup Failed

-

{message}

-
-

You can close this window and try again in ComfyUI.

-
-
- - """, content_type='text/html', status=500) - - else: - # Fallback for sessions without state tracking - print("[OAuthCallback] No session found, attempting generic processing") - return web.Response(text=f""" - - - - - Dropbox Authorization Code - - - -
-
πŸ”„
-

Authorization Code Received

-

Please return to ComfyUI and manually enter this code:

-
{auth_code}
-
-

πŸ“‹ Select the code above and copy it to ComfyUI

-
-
- - """, content_type='text/html') - - except Exception as e: - print(f"[OAuthCallback] Unexpected error: {e}") - return web.Response(text=f""" - -

❌ Callback Error

-

An unexpected error occurred: {e}

-

You can close this window and try again in ComfyUI.

- - """, content_type='text/html', status=500) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4654926..d97b0f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui-dropsend-node" description = "Automatically upload ComfyUI output files to Dropbox with optional encryption. Supports real-time monitoring, cloud setups (RunPod), and includes cross-platform decryption scripts." -version = "1.0.3" +version = "1.1.0" license = { file = "LICENSE" } dependencies = ["requests", "python-dotenv", "watchdog", "dropbox", "cryptography"] diff --git a/safe_paths.py b/safe_paths.py new file mode 100644 index 0000000..e5d5450 --- /dev/null +++ b/safe_paths.py @@ -0,0 +1,144 @@ +# safe_paths.py +# +# Path validation helpers used by DropSend nodes to constrain user-supplied +# paths to safe locations. +# +# Threat model: a ComfyUI instance may be reachable over the network. Any +# workflow author can therefore set node inputs. Without restriction, the +# DropSend uploader's watch_folder input becomes an arbitrary-file-read +# primitive β€” the watcher recursively uploads everything under the path to +# the operator's Dropbox account. We constrain watch_folder to the ComfyUI +# output directory tree, with an operator-controlled allowlist for advanced +# setups. + +import os +import logging + +logger = logging.getLogger(__name__) + + +def get_output_root(): + """Return the absolute, real path of ComfyUI's output directory. + + Falls back to /output if folder_paths is unavailable (e.g. running + unit tests outside a ComfyUI process). + """ + try: + import folder_paths # type: ignore + root = folder_paths.get_output_directory() + except Exception: + root = os.path.join(os.getcwd(), "output") + return os.path.realpath(root) + + +def get_allowed_roots(): + """Return the list of directory roots a watch_folder is allowed to be in. + + Always includes the ComfyUI output directory. Operators who need to + monitor additional locations (e.g. a custom output mount) can opt in by + setting COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS to an os.pathsep-separated + list of absolute paths. Workflow inputs cannot expand this list. + """ + roots = [get_output_root()] + extra = os.environ.get("COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS", "") + if extra: + for raw in extra.split(os.pathsep): + raw = raw.strip() + if raw: + roots.append(os.path.realpath(raw)) + return roots + + +def _is_within(child, parent): + """True iff `child` is `parent` or a descendant of it (after realpath).""" + try: + return os.path.commonpath([child, parent]) == parent + except ValueError: + # Different drives on Windows, etc. + return False + + +def resolve_safe_watch_folder(user_input): + """Resolve a user-supplied watch_folder to a safe absolute path. + + - Empty input β†’ the ComfyUI output directory. + - Relative input β†’ joined under the output directory. + - Absolute input β†’ must resolve under one of the allowed roots. + + Raises ValueError with a clear message if the input escapes the allowed + set (path traversal, symlinks pointing outside, unrelated absolute path). + """ + output_root = get_output_root() + allowed_roots = get_allowed_roots() + + if user_input is None or str(user_input).strip() == "": + return output_root + + candidate = str(user_input).strip() + + if not os.path.isabs(candidate): + candidate = os.path.join(output_root, candidate) + + resolved = os.path.realpath(candidate) + + for root in allowed_roots: + if _is_within(resolved, root): + return resolved + + raise ValueError( + "watch_folder must be inside the ComfyUI output directory " + f"({output_root}). Got: {user_input!r} (resolved: {resolved}). " + "If you need to monitor a different location, set the " + "COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS environment variable on the " + "host before starting ComfyUI." + ) + + +def is_safe_event_path(path): + """True iff a Watchdog file event path is safe to process. + + Rejects (a) symlinks and (b) paths whose realpath escapes the allowed + roots. Defends against symlinks placed inside the watched directory + that point at sensitive files outside it (e.g. an output/leak.png + -> ~/.ssh/id_rsa would otherwise be read, encrypted, and uploaded by + the watchers). + """ + try: + if os.path.islink(path): + return False + resolved = os.path.realpath(path) + except OSError: + return False + + for root in get_allowed_roots(): + if _is_within(resolved, root): + return True + return False + + +def validate_dropbox_dest(dest): + """Validate a Dropbox destination folder string. + + Dropbox paths are POSIX-style absolute paths. We reject empty values, + paths missing the leading slash, parent-traversal segments, and NUL + bytes. This is defense-in-depth β€” Dropbox itself does not interpret + `..` β€” but it keeps malformed inputs from being silently accepted. + """ + if dest is None or str(dest).strip() == "": + raise ValueError("dropbox_dest_folder must not be empty.") + + value = str(dest).strip() + + if "\x00" in value: + raise ValueError("dropbox_dest_folder contains a NUL byte.") + if not value.startswith("/"): + raise ValueError( + f"dropbox_dest_folder must start with '/'. Got: {value!r}" + ) + parts = [p for p in value.split("/") if p] + if any(p == ".." for p in parts): + raise ValueError( + f"dropbox_dest_folder must not contain '..' segments. Got: {value!r}" + ) + + return "/" + "/".join(parts) if parts else "/" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_safe_paths.py b/tests/test_safe_paths.py new file mode 100644 index 0000000..016ec39 --- /dev/null +++ b/tests/test_safe_paths.py @@ -0,0 +1,230 @@ +"""Security tests for safe_paths.py β€” the only barrier between +remote workflow inputs and arbitrary-file-read on the host. + +Covers: + β€’ is_safe_event_path: symlink rejection, path-traversal rejection, + legitimate-path acceptance, non-existent-path handling. + β€’ validate_dropbox_dest: NUL bytes, missing leading slash, `..` + segments, empty inputs. + β€’ resolve_safe_watch_folder: relative join, absolute clamp to + output root, symlink-escape rejection, allowlist via env var. + +Run with: + python -m unittest tests.test_safe_paths +or: + python tests/test_safe_paths.py +""" + +import os +import sys +import shutil +import tempfile +import unittest + +# Add the plugin root so we can import the module by name. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import safe_paths + + +class _BaseSafePathTest(unittest.TestCase): + """Set up an isolated tempdir as the fake ComfyUI output root and + monkey-patch safe_paths.get_output_root to return it. Each test + runs in its own directory so file/symlink fixtures don't leak.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="dropsend-test-") + # Real-realpath: macOS may resolve /tmp -> /private/tmp. + self.output_root = os.path.realpath(self.tmp) + self._orig_get_output_root = safe_paths.get_output_root + safe_paths.get_output_root = lambda: self.output_root + # Clear the allowlist env var so it doesn't bleed in from the + # caller's shell. + self._orig_allow = os.environ.pop("COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS", None) + + def tearDown(self): + safe_paths.get_output_root = self._orig_get_output_root + if self._orig_allow is not None: + os.environ["COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS"] = self._orig_allow + shutil.rmtree(self.tmp, ignore_errors=True) + + +class TestIsSafeEventPath(_BaseSafePathTest): + """is_safe_event_path is the gate watchdog calls on every file + event. False β†’ file is ignored (no upload, no encrypt). True β†’ + file is processed. Anything that lets a symlink or out-of-tree + path through becomes an arbitrary-file-read primitive.""" + + def test_regular_file_inside_root_is_safe(self): + path = os.path.join(self.output_root, "image.png") + with open(path, "wb") as f: + f.write(b"x") + self.assertTrue(safe_paths.is_safe_event_path(path)) + + def test_regular_file_in_subdir_is_safe(self): + sub = os.path.join(self.output_root, "subdir") + os.makedirs(sub) + path = os.path.join(sub, "image.png") + with open(path, "wb") as f: + f.write(b"x") + self.assertTrue(safe_paths.is_safe_event_path(path)) + + def test_symlink_inside_root_is_rejected(self): + target = os.path.join(self.tmp, "..", "outside-target") + target = os.path.abspath(target) + with open(target, "wb") as f: + f.write(b"sensitive") + try: + link = os.path.join(self.output_root, "leak.png") + os.symlink(target, link) + # Even though `link` is inside the watched root, it's a + # symlink -> it must be rejected. + self.assertFalse(safe_paths.is_safe_event_path(link)) + finally: + try: os.unlink(target) + except OSError: pass + + def test_symlink_to_sensitive_target_is_rejected(self): + # Classic exfiltration pattern: a symlink inside the watched + # tree points at ~/.ssh/id_rsa or similar. + sensitive = os.path.expanduser("~/.ssh") # may or may not exist; doesn't matter + link = os.path.join(self.output_root, "id_rsa.png") + try: + os.symlink(sensitive, link) + self.assertFalse(safe_paths.is_safe_event_path(link)) + except OSError: + self.skipTest("could not create test symlink in tempdir") + + def test_path_outside_root_is_rejected(self): + # File exists but is outside the watched root. + outside = os.path.join(self.tmp, "..", "outside.png") + outside = os.path.abspath(outside) + with open(outside, "wb") as f: + f.write(b"x") + try: + self.assertFalse(safe_paths.is_safe_event_path(outside)) + finally: + try: os.unlink(outside) + except OSError: pass + + def test_directory_traversal_string_is_rejected(self): + # The literal path string contains "..". Even if the string + # textually starts with the root, after realpath() it must + # still be inside the root for is_safe_event_path to return + # True. Here we craft a path that resolves outside. + path = os.path.join(self.output_root, "..", "..", "etc", "passwd") + # `path` does not exist under our tmpdir; what matters is + # that realpath escapes the root, which is_safe_event_path + # must catch. + self.assertFalse(safe_paths.is_safe_event_path(path)) + + def test_allowlist_extra_root_accepted(self): + # Operator opts a second root in via env var. Files under + # that root must now be accepted. + extra = tempfile.mkdtemp(prefix="dropsend-extra-") + try: + os.environ["COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS"] = extra + extra_real = os.path.realpath(extra) + f = os.path.join(extra_real, "image.png") + with open(f, "wb") as fh: + fh.write(b"x") + self.assertTrue(safe_paths.is_safe_event_path(f)) + finally: + os.environ.pop("COMFYUI_DROPSEND_ALLOWED_WATCH_PATHS", None) + shutil.rmtree(extra, ignore_errors=True) + + +class TestValidateDropboxDest(unittest.TestCase): + """validate_dropbox_dest is what stops a hostile workflow from + sending an `app_secret`-leaking dest like '../../etc/passwd' + over the wire to Dropbox (which would just normalize and accept + it, but still β€” defense in depth).""" + + def test_empty_string_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest("") + + def test_none_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest(None) + + def test_whitespace_only_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest(" ") + + def test_missing_leading_slash_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest("Apps/Foo") + + def test_parent_traversal_segment_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest("/Apps/Foo/../etc/passwd") + + def test_nul_byte_is_rejected(self): + with self.assertRaises(ValueError): + safe_paths.validate_dropbox_dest("/Apps/Foo\x00Bar") + + def test_valid_path_returns_normalized(self): + result = safe_paths.validate_dropbox_dest("/Apps/Foo/Bar") + self.assertEqual(result, "/Apps/Foo/Bar") + + def test_double_slashes_normalized(self): + # split-and-rejoin should collapse repeated slashes + result = safe_paths.validate_dropbox_dest("/Apps//Foo///Bar") + self.assertEqual(result, "/Apps/Foo/Bar") + + def test_root_only_is_accepted(self): + result = safe_paths.validate_dropbox_dest("/") + self.assertEqual(result, "/") + + +class TestResolveSafeWatchFolder(_BaseSafePathTest): + """resolve_safe_watch_folder is the only thing standing between a + remote workflow input and 'watch + upload my entire ~/.ssh'. Test + that the clamp to output_root is real and that legitimate flows + still work.""" + + def test_empty_input_returns_output_root(self): + self.assertEqual(safe_paths.resolve_safe_watch_folder(""), self.output_root) + + def test_none_input_returns_output_root(self): + self.assertEqual(safe_paths.resolve_safe_watch_folder(None), self.output_root) + + def test_relative_input_joined_under_output(self): + sub = os.path.join(self.output_root, "subdir") + os.makedirs(sub) + result = safe_paths.resolve_safe_watch_folder("subdir") + self.assertEqual(result, sub) + + def test_absolute_input_inside_output_accepted(self): + sub = os.path.join(self.output_root, "subdir") + os.makedirs(sub) + result = safe_paths.resolve_safe_watch_folder(sub) + self.assertEqual(result, sub) + + def test_absolute_input_outside_output_rejected(self): + with self.assertRaises(ValueError): + safe_paths.resolve_safe_watch_folder("/etc") + + def test_traversal_back_to_root_rejected(self): + with self.assertRaises(ValueError): + # Even though the string is a relative path, after + # realpath it escapes the root. + safe_paths.resolve_safe_watch_folder("../../etc") + + def test_symlink_pointing_outside_root_rejected(self): + # An attacker-supplied watch_folder that's a symlink to /etc + # must be rejected even though the symlink itself was created + # inside the watched dir. + outside = os.path.realpath(tempfile.mkdtemp(prefix="dropsend-out-")) + try: + link = os.path.join(self.output_root, "shortcut") + os.symlink(outside, link) + with self.assertRaises(ValueError): + safe_paths.resolve_safe_watch_folder(link) + finally: + shutil.rmtree(outside, ignore_errors=True) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_setup_node_security.py b/tests/test_setup_node_security.py new file mode 100644 index 0000000..027fb41 --- /dev/null +++ b/tests/test_setup_node_security.py @@ -0,0 +1,221 @@ +"""Regression tests for security-critical patterns in +dropsend_setup_node.py. + +These run static AST analysis against the source file rather than +invoking setup() at runtime. Reasons: + β€’ setup() pulls in aiohttp, requests, dropbox, the Dropbox API, + and several global modules. A behavioral test would be heavy. + β€’ The patterns we're guarding against are syntactic β€” "is there + a send_sync call without a sid argument" is exactly the kind + of regression an AST walk catches deterministically. + +Catches: + β€’ The original WebSocket-broadcast vulnerability (send_sync with + sid=None broadcasts to every connected client). Any future + change that drops the `if sid:` guard or hardcodes None as the + third argument fails this test loudly at CI time. + +Run with: + python -m unittest tests.test_setup_node_security +or: + python tests/test_setup_node_security.py +""" + +import ast +import os +import sys +import unittest + + +_SETUP_NODE_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "dropsend_setup_node.py", +) + +# WebSocket events that carry credential data or trigger credential- +# adjacent UI changes. Any send_sync of one of these MUST NOT be a +# broadcast (sid=None). +CREDENTIAL_EVENTS = frozenset({ + "dropsend_credentials_ready", + "dropsend_credentials_needed", + "dropbox_reconnect_complete", +}) + + +def _iter_send_sync_calls(tree): + """Yield every call to a `*.send_sync(...)` attribute in the tree.""" + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr == "send_sync": + yield node + + +def _first_arg_is_credential_event(call): + """If the first positional arg is a string literal naming a + credential-class event, return that string; otherwise None.""" + if not call.args: + return None + arg = call.args[0] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + if arg.value in CREDENTIAL_EVENTS: + return arg.value + return None + + +class TestNoBroadcastForCredentialEvents(unittest.TestCase): + """The dangerous pattern is `PromptServer.instance.send_sync(EVENT, + DATA)` (no third arg) or `send_sync(EVENT, DATA, None)`. Both + cause aiohttp's send_json to iterate every connected websocket + and deliver to all of them β€” i.e. a credential broadcast. + + Every credential-class send_sync in the setup node must have a + third argument, and that third argument must not be a `None` + literal.""" + + @classmethod + def setUpClass(cls): + with open(_SETUP_NODE_PATH, "r", encoding="utf-8") as f: + cls.tree = ast.parse(f.read(), filename=_SETUP_NODE_PATH) + + def test_every_credential_send_sync_supplies_a_sid(self): + offenders = [] + for call in _iter_send_sync_calls(self.tree): + event = _first_arg_is_credential_event(call) + if event is None: + continue + # Inspect the third positional or `sid=` keyword argument. + sid_arg = None + if len(call.args) >= 3: + sid_arg = call.args[2] + else: + for kw in call.keywords: + if kw.arg == "sid": + sid_arg = kw.value + break + if sid_arg is None: + offenders.append( + f"send_sync({event!r}, ...) at line {call.lineno} " + "supplies no sid argument; this would broadcast to " + "every connected WebSocket client." + ) + continue + if isinstance(sid_arg, ast.Constant) and sid_arg.value is None: + offenders.append( + f"send_sync({event!r}, ..., None) at line {call.lineno} " + "passes None as sid; this would broadcast to every " + "connected WebSocket client." + ) + if offenders: + self.fail( + "Credential events must never be broadcast. Offenders:\n " + + "\n ".join(offenders) + ) + + def test_every_credential_send_sync_is_inside_a_truthy_sid_check(self): + """Static-analysis defense in depth: walk every credential + send_sync and confirm it sits inside an `if sid:` (or `if X:`) + block. Catches the case where someone supplies a truthy-looking + variable that's actually `None` at runtime β€” the surrounding + guard is what proves the variable is non-empty.""" + offenders = [] + # Build parent links so we can walk upward from each call. + parent_of = {} + for parent in ast.walk(self.tree): + for child in ast.iter_child_nodes(parent): + parent_of[id(child)] = parent + + def is_inside_truthy_check(node): + cur = parent_of.get(id(node)) + while cur is not None: + if isinstance(cur, ast.If): + test = cur.test + # Accept `if sid:` and `if some_var:` (Name node) + # and `if sid is not None:` style. + if isinstance(test, ast.Name): + return True + if ( + isinstance(test, ast.Compare) + and len(test.ops) == 1 + and isinstance(test.ops[0], (ast.IsNot, ast.NotEq)) + and isinstance(test.comparators[0], ast.Constant) + and test.comparators[0].value is None + ): + return True + if ( + isinstance(test, ast.UnaryOp) + and isinstance(test.op, ast.Not) + ): + # `if not sid: ... else: send_sync(...)` is + # also an acceptable guard. + return True + cur = parent_of.get(id(cur)) + return False + + for call in _iter_send_sync_calls(self.tree): + event = _first_arg_is_credential_event(call) + if event is None: + continue + if not is_inside_truthy_check(call): + offenders.append( + f"send_sync({event!r}, ...) at line {call.lineno} " + "is not inside an `if sid:` (or equivalent) guard. " + "Without the guard, a falsy/missing client_id " + "would silently broadcast credentials." + ) + if offenders: + self.fail( + "Credential events must be inside a truthy-sid check. " + "Offenders:\n " + "\n ".join(offenders) + ) + + +class TestSetupNodeHasNoCredentialInputs(unittest.TestCase): + """The Setup Node's INPUT_TYPES must not declare app_key, + app_secret, or auth_code as workflow inputs. The whole point of + the modal architecture is that these never enter the prompt JSON; + if they reappear in INPUT_TYPES, every leak vector we closed + (workflow JSON, PNG metadata, /history, etc.) silently reopens.""" + + FORBIDDEN_INPUT_NAMES = frozenset({"app_key", "app_secret", "auth_code"}) + + @classmethod + def setUpClass(cls): + with open(_SETUP_NODE_PATH, "r", encoding="utf-8") as f: + cls.source = f.read() + cls.tree = ast.parse(cls.source, filename=_SETUP_NODE_PATH) + + def test_input_types_does_not_declare_credential_fields(self): + # Find INPUT_TYPES in DropSendSetupNode. It returns a dict + # literal of dicts; we check the keys at the inner level. + offenders = [] + for node in ast.walk(self.tree): + if not isinstance(node, ast.FunctionDef): + continue + if node.name != "INPUT_TYPES": + continue + for sub in ast.walk(node): + if not isinstance(sub, ast.Dict): + continue + for key in sub.keys: + if ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and key.value in self.FORBIDDEN_INPUT_NAMES + ): + offenders.append( + f"{key.value!r} appears as a key in an INPUT_TYPES " + f"dict at line {key.lineno}. Setup-Node credentials " + "must NOT be workflow inputs β€” they go through the " + "modal + stash route, never the prompt body." + ) + if offenders: + self.fail( + "Forbidden credential fields declared as workflow inputs:\n " + + "\n ".join(offenders) + ) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/web/dropbox_oauth.js b/web/dropbox_oauth.js index 2a15311..dffaa4b 100644 --- a/web/dropbox_oauth.js +++ b/web/dropbox_oauth.js @@ -1,194 +1,582 @@ -// dropbox_oauth.js - ComfyUI extension for seamless Dropbox OAuth flow +// 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"; -// Track OAuth popup windows +// --------------------------------------------------------------------------- +// 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"); - - // Listen for WebSocket messages about OAuth completion - api.addEventListener("dropbox_oauth_complete", (event) => { - console.log("[DropSendNode] Received OAuth completion notification via WebSocket", event.detail); - - const data = event.detail; - if (data.success) { - console.log("[DropSendNode] OAuth successful"); - this.handleOAuthSuccess(); - } else { - console.error("[DropSendNode] OAuth failed:", data.message); - } - }); - - // Listen for WebSocket messages about reconnect completion + + // 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] Received reconnect completion notification via WebSocket", event.detail); - + console.log("[DropSendNode] dropbox_reconnect_complete:", event.detail); const data = event.detail; - if (data.success) { - console.log("[DropSendNode] Reconnect successful"); + if (data && data.success) { this.handleReconnectSuccess(); } }); - - // Listen for postMessage from OAuth popup window - window.addEventListener("message", (event) => { - if (event.data && event.data.type === 'dropbox_oauth_complete') { - console.log("[DropSendNode] Received OAuth completion notification via postMessage", event.data); - - if (event.data.success) { - console.log("[DropSendNode] OAuth successful"); - this.handleOAuthSuccess(); - } else { - console.error("[DropSendNode] OAuth failed:", event.data.message); - } + + // 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); }); }, - - handleOAuthSuccess() { - // Don't auto-close popup - let user close manually after seeing success message - console.log("[DropSendNode] OAuth success detected - popup will stay open for user to close manually"); - - // Reset any reconnect checkboxes - this.resetReconnectFields(); - - // Note: Manual refresh required if user wants to update UI - console.log("[DropSendNode] OAuth success handled - manual refresh available"); - }, - + resetReconnectFields() { try { - // Find all DropboxSetupNode instances and reset their reconnect fields - const nodes = app.graph.nodes.filter(node => node.type === "DropboxSetupNode"); - nodes.forEach(node => { - if (node.widgets) { - const reconnectWidget = node.widgets.find(w => w.name === "reconnect"); - if (reconnectWidget && reconnectWidget.value === true) { - console.log("[DropSendNode] Resetting reconnect field to false"); - reconnectWidget.value = false; - if (node.onWidgetChanged) { - node.onWidgetChanged("reconnect", false); - } - } + 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() { - // For reconnect, we DO want auto-refresh to show the auth fields again - console.log("[DropSendNode] Reconnect success - refreshing interface to show auth fields"); - - // Reset any cached state that might prevent popup detection - this.resetReconnectFields(); - - // Refresh ComfyUI interface to show auth fields after reconnect - setTimeout(() => { - console.log("[DropSendNode] Refreshing ComfyUI interface after reconnect"); - window.location.reload(); - }, 1000); // Short delay for reconnect - }, - - // Override node execution to detect and handle OAuth URLs - async beforeRegisterNodeDef(nodeType, nodeData, app) { - if (nodeData.name === "DropboxSetupNode") { - console.log("[DropSendNode] Registered DropboxSetupNode enhancement"); - - // Add custom OAuth popup handling - const originalExecuted = nodeType.prototype.onExecuted; - nodeType.prototype.onExecuted = function(message) { - console.log("[DropSendNode] Node executed with message:", message); - - // Check if this execution includes OAuth URL generation - if (message && typeof message === 'object' && message.text && message.text[0]) { - const text = message.text[0]; - console.log("[DropSendNode] Message text content:", text.substring(0, 100) + "..."); - - if (text.includes('Dropbox OAuth Ready!')) { - console.log("[DropSendNode] Detected Dropbox OAuth ready message"); - - // Extract OAuth URL from the message - const urlMatch = text.match(/(https:\/\/www\.dropbox\.com\/oauth2\/authorize[^\s]+)/); - if (urlMatch) { - const oauthUrl = urlMatch[1]; - console.log("[DropSendNode] Extracted OAuth URL:", oauthUrl); - - // Auto-open the popup after a brief delay - setTimeout(() => { - console.log("[DropSendNode] Auto-opening OAuth popup..."); - window.openDropboxOAuth(oauthUrl); - }, 500); - } else { - console.log("[DropSendNode] No OAuth URL found in message"); - } - } else { - console.log("[DropSendNode] Message does not contain 'Dropbox OAuth Ready!' trigger"); - } - } else { - console.log("[DropSendNode] Message structure not recognized for OAuth detection"); - } - - if (originalExecuted) { - return originalExecuted.apply(this, arguments); - } - }; - } - } -}); -// Utility function to open OAuth in a small popup window -window.openDropboxOAuth = function(url) { - console.log("[DropSendNode] Opening OAuth popup:", url); - - // Close existing popup if open - if (oauthPopup && !oauthPopup.closed) { - oauthPopup.close(); - } - - // Open small popup window - 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; - - // Focus the popup - popup.focus(); - - console.log("[DropSendNode] OAuth popup opened successfully"); - - // Monitor popup closure with more detailed logging - const checkClosed = setInterval(() => { - if (popup.closed) { - console.log("[DropSendNode] OAuth popup closed by user or completed"); - clearInterval(checkClosed); - oauthPopup = null; + 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); } - }, 500); // Check more frequently - - // Also monitor popup URL changes (if possible) - try { - popup.addEventListener('beforeunload', () => { - console.log("[DropSendNode] OAuth popup is navigating"); - }); - } catch (e) { - console.log("[DropSendNode] Cannot monitor popup navigation (cross-origin)"); - } - - return popup; - } else { - console.error("[DropSendNode] Could not open OAuth popup - popup blocked?"); - return null; - } -}; \ No newline at end of file + 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); + }; + }, +});