v1.0.0: Fix encryption architecture, add registry support

- Separate encrypt watcher creates .enc copies (preserves originals)
   - Upload watcher only uploads .enc files when encryption enabled
   - Prevents ComfyUI from overwriting files due to missing sequence numbers
   - Added pyproject.toml and GitHub Actions for ComfyUI Registry
This commit is contained in:
MachinePainting
2026-02-01 06:38:38 -08:00
parent 267620bd98
commit 6ad746abc1
5 changed files with 308 additions and 384 deletions
+20
View File
@@ -0,0 +1,20 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+1 -1
View File
@@ -277,5 +277,5 @@ NODE_CLASS_MAPPINGS = {
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DriveSendSetup": "DriveSend Setup"
"DriveSendSetup": "🚙⚙️ DriveSend - Setup Node"
}
+101 -149
View File
@@ -1,12 +1,27 @@
"""
File encryption module for DriveSend
Uses Fernet (AES-128) symmetric encryption
"""
# encrypt_file.py
# DriveSend encryption module - creates .enc copies of files (preserves originals for ComfyUI)
import os
import hashlib
from pathlib import Path
import time
import logging
from queue import Queue
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from cryptography.fernet import Fernet
import threading
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('drivesend.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
ENCRYPT_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.avi', '.mov')
_stop_queue_processor = False # Stop signal for queue processor
def get_encryption_key():
@@ -33,151 +48,88 @@ def get_encryption_key():
)
def encrypt_file(input_path, output_path=None, key=None):
"""
Encrypt a file using Fernet (AES-128).
Args:
input_path: Path to the file to encrypt
output_path: Path for the encrypted file (default: input_path + '.enc')
key: Encryption key (default: from environment variable)
Returns:
dict with encryption result:
- success: bool
- encrypted_path: str (if successful)
- checksum: str (SHA256 of original file)
- error: str (if failed)
"""
try:
input_path = Path(input_path)
if not input_path.exists():
return {"success": False, "error": f"File not found: {input_path}"}
# Get encryption key
if key is None:
key = get_encryption_key()
if not key:
return {"success": False, "error": "No encryption key found. Set COMFYUI_ENCRYPTION_KEY environment variable."}
# Ensure key is bytes
if isinstance(key, str):
key = key.encode()
# Create Fernet cipher
try:
fernet = Fernet(key)
except Exception as e:
return {"success": False, "error": f"Invalid encryption key: {e}"}
# Read file
with open(input_path, 'rb') as f:
data = f.read()
# Calculate checksum of original file
checksum = hashlib.sha256(data).hexdigest()
# Encrypt
encrypted_data = fernet.encrypt(data)
# Determine output path
if output_path is None:
output_path = Path(str(input_path) + '.enc')
else:
output_path = Path(output_path)
# Write encrypted file
with open(output_path, 'wb') as f:
f.write(encrypted_data)
return {
"success": True,
"encrypted_path": str(output_path),
"original_path": str(input_path),
"checksum": checksum,
"original_size": len(data),
"encrypted_size": len(encrypted_data)
}
except Exception as e:
return {"success": False, "error": str(e)}
def stop_queue_processor():
"""Signal the queue processor to stop."""
global _stop_queue_processor
_stop_queue_processor = True
logger.info("[DriveSend] Signaled encryption queue processor to stop")
def decrypt_file(input_path, output_path=None, key=None):
def reset_queue_processor():
"""Reset the stop signal for queue processor."""
global _stop_queue_processor
_stop_queue_processor = False
class FileEncryptHandler(FileSystemEventHandler):
"""
Decrypt a file using Fernet (AES-128).
Args:
input_path: Path to the encrypted file
output_path: Path for the decrypted file (default: removes .enc extension)
key: Encryption key (default: from environment variable)
Returns:
dict with decryption result:
- success: bool
- decrypted_path: str (if successful)
- checksum: str (SHA256 of decrypted file)
- error: str (if failed)
Watches for new image/video files and creates encrypted .enc copies.
Original files are preserved so ComfyUI maintains its sequential naming.
"""
def __init__(self, watch_dir="/workspace/ComfyUI/output", delete_original=False, subfolder_monitor=True):
self.watch_dir = watch_dir
self.delete_original = delete_original
self.subfolder_monitor = subfolder_monitor
self.file_queue = Queue()
self.start_queue_processor()
def on_created(self, event):
if not event.is_directory and event.src_path.lower().endswith(ENCRYPT_EXTENSIONS):
logger.info(f"[DriveSend] Detected new file to encrypt: {event.src_path}")
self.file_queue.put(event.src_path)
def start_queue_processor(self):
def process_queue():
while not _stop_queue_processor:
if not self.file_queue.empty():
file_path = self.file_queue.get()
try:
time.sleep(1) # Ensure file is fully written
size1 = os.path.getsize(file_path)
time.sleep(0.1)
size2 = os.path.getsize(file_path)
if size1 != size2:
logger.warning(f"[DriveSend] File {file_path} still writing, requeueing...")
self.file_queue.put(file_path)
continue
ENCRYPT_KEY = get_encryption_key()
if not ENCRYPT_KEY:
logger.error("[DriveSend] Encryption key not found. Set COMFYUI_ENCRYPTION_KEY environment variable.")
continue
fernet = Fernet(ENCRYPT_KEY.encode())
with open(file_path, 'rb') as f:
file_data = f.read()
encrypted_data = fernet.encrypt(file_data)
base_name = os.path.splitext(file_path)[0]
enc_path = base_name + '.enc'
with open(enc_path, 'wb') as f:
f.write(encrypted_data)
logger.info(f"[DriveSend] Created encrypted file: {enc_path}")
# Original file is kept so ComfyUI maintains sequential naming
# User must manually delete originals from output folder
except Exception as e:
logger.error(f"[DriveSend] Error encrypting {file_path}: {e}")
time.sleep(0.1) # Small delay to avoid CPU overload
logger.info("[DriveSend] Encryption queue processor stopped")
threading.Thread(target=process_queue, daemon=True).start()
if __name__ == "__main__":
watch_dir = os.getenv("WATCH_DIR", "/workspace/ComfyUI/output")
os.makedirs(watch_dir, exist_ok=True)
observer = Observer()
observer.schedule(FileEncryptHandler(watch_dir), path=watch_dir, recursive=True)
observer.start()
logger.info(f"[DriveSend] Monitoring {watch_dir} for new files to encrypt...")
try:
input_path = Path(input_path)
if not input_path.exists():
return {"success": False, "error": f"File not found: {input_path}"}
# Get encryption key
if key is None:
key = get_encryption_key()
if not key:
return {"success": False, "error": "No encryption key found. Set COMFYUI_ENCRYPTION_KEY environment variable."}
# Ensure key is bytes
if isinstance(key, str):
key = key.encode()
# Create Fernet cipher
try:
fernet = Fernet(key)
except Exception as e:
return {"success": False, "error": f"Invalid encryption key: {e}"}
# Read encrypted file
with open(input_path, 'rb') as f:
encrypted_data = f.read()
# Decrypt
try:
decrypted_data = fernet.decrypt(encrypted_data)
except Exception as e:
return {"success": False, "error": f"Decryption failed (wrong key?): {e}"}
# Calculate checksum of decrypted file
checksum = hashlib.sha256(decrypted_data).hexdigest()
# Determine output path
if output_path is None:
# Remove .enc extension if present
if input_path.suffix == '.enc':
output_path = input_path.with_suffix('')
else:
output_path = Path(str(input_path) + '.decrypted')
else:
output_path = Path(output_path)
# Write decrypted file
with open(output_path, 'wb') as f:
f.write(decrypted_data)
return {
"success": True,
"decrypted_path": str(output_path),
"encrypted_path": str(input_path),
"checksum": checksum,
"decrypted_size": len(decrypted_data)
}
except Exception as e:
return {"success": False, "error": str(e)}
while True:
time.sleep(1)
except KeyboardInterrupt:
observer.stop()
observer.join()
+172 -234
View File
@@ -1,45 +1,35 @@
"""
Output Monitor Module
Watches a directory for new files and triggers uploads
"""
# monitor_output.py
# DriveSend upload monitor - watches for files and uploads to Google Drive
import os
import time
import threading
import queue
import logging
from pathlib import Path
from queue import Queue
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from .gdrive_upload import upload_file
from .encrypt_file import encrypt_file, get_encryption_key
from .encrypt_file import ENCRYPT_EXTENSIONS
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('drivesend.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Supported file extensions
SUPPORTED_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.avi', '.mov'}
# Global stop signal for queue processor
_stop_queue_processor = False
watcher_observer = None
watcher_handler = None
encryption_enabled = False
_stop_queue_processor = False # Stop signal for queue processor
_drive_service = None # Cached Drive service
def wait_for_complete_write(file_path, timeout=10):
"""
Wait for a file to be completely written before processing.
Checks if file size is stable for 2 consecutive checks (1 second).
Args:
file_path: Path to the file
timeout: Maximum time to wait in seconds
Returns:
bool: True if file is ready, False if timeout
"""
"""Wait for a file to be completely written before processing."""
last_size = -1
stable_count = 0
checks = 0
@@ -49,7 +39,7 @@ def wait_for_complete_write(file_path, timeout=10):
current_size = os.path.getsize(file_path)
if current_size == last_size:
stable_count += 1
if stable_count >= 2: # File size stable for 2 checks (1 second)
if stable_count >= 2:
return True
else:
stable_count = 0
@@ -59,239 +49,187 @@ def wait_for_complete_write(file_path, timeout=10):
time.sleep(0.5)
checks += 1
logger.warning(f"Timeout: File may still be writing: {file_path}")
logger.warning(f"[DriveSend] Timeout: File may still be writing: {file_path}")
return False
class UploadHandler(FileSystemEventHandler):
"""Handles file system events and queues files for upload."""
def stop_queue_processor():
"""Signal the queue processor to stop."""
global _stop_queue_processor
_stop_queue_processor = True
logger.info("[DriveSend] Signaled upload queue processor to stop")
def reset_queue_processor():
"""Reset the stop signal for queue processor."""
global _stop_queue_processor
_stop_queue_processor = False
class NewFileHandler(FileSystemEventHandler):
"""
Handles new file events and queues them for upload.
When encryption is enabled, only uploads .enc files.
When encryption is disabled, uploads image/video files directly.
"""
def __init__(self, upload_queue, recursive=True, enable_encryption=False):
def __init__(self, folder_id, auth_method='oauth', delete_enc=False):
super().__init__()
self.upload_queue = upload_queue
self.recursive = recursive
self.enable_encryption = enable_encryption
self.processed_files = set()
self.folder_id = folder_id
self.auth_method = auth_method
self.delete_enc = delete_enc
self.file_queue = Queue()
self.start_queue_processor()
def on_created(self, event):
if event.is_directory:
return
file_path = event.src_path
file_path = Path(event.src_path)
# Handle encryption mode file filtering
if self.enable_encryption:
# When encryption is enabled, only process .enc files
if file_path.suffix.lower() != '.enc':
return
else:
# When encryption is disabled, skip .enc files and check supported extensions
if file_path.suffix.lower() == '.enc':
return
if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
return
# Avoid processing the same file multiple times
if str(file_path) in self.processed_files:
# When encryption is enabled, only process .enc files
if encryption_enabled and not file_path.lower().endswith('.enc'):
logger.debug(f"[DriveSend] Skipping non-.enc file (encryption enabled): {file_path}")
return
self.processed_files.add(str(file_path))
# When encryption is disabled, skip .enc files and only process supported extensions
if not encryption_enabled:
if file_path.lower().endswith('.enc'):
logger.debug(f"[DriveSend] Skipping .enc file (encryption disabled): {file_path}")
return
if not file_path.lower().endswith(ENCRYPT_EXTENSIONS):
logger.debug(f"[DriveSend] Skipping unsupported file type: {file_path}")
return
# Add to queue (wait_for_complete_write happens in worker)
self.upload_queue.put(str(file_path))
print(f"[DriveSend] Queued: {file_path.name}")
logger.info(f"[DriveSend] Detected new file: {file_path}")
self.file_queue.put(file_path)
class OutputMonitor:
"""Monitors a directory and uploads new files to Google Drive."""
def __init__(
self,
watch_dir,
folder_id=None,
recursive=True,
enable_encryption=False,
post_delete_enc=False,
auth_method='service_account',
owner_email=None,
**auth_kwargs
):
self.watch_dir = Path(watch_dir)
self.folder_id = folder_id or os.environ.get('GOOGLE_DRIVE_FOLDER_ID')
self.recursive = recursive
self.enable_encryption = enable_encryption
self.post_delete_enc = post_delete_enc
self.auth_method = auth_method
self.owner_email = owner_email or os.environ.get('GOOGLE_OWNER_EMAIL')
self.auth_kwargs = auth_kwargs
self.upload_queue = queue.Queue()
self.observer = None
self.upload_thread = None
self.running = False
self.service = None
def _upload_worker(self):
"""Worker thread that processes the upload queue."""
global _stop_queue_processor
from .gdrive_auth_manager import get_drive_service
# Get authenticated service
try:
self.service = get_drive_service(self.auth_method, **self.auth_kwargs)
except Exception as e:
print(f"[DriveSend] Failed to authenticate: {e}")
return
while self.running and not _stop_queue_processor:
try:
# Get file from queue with timeout
file_path = self.upload_queue.get(timeout=1)
except queue.Empty:
continue
def start_queue_processor(self):
def process_queue():
global _drive_service
# Wait for file to be completely written
if not wait_for_complete_write(file_path):
print(f"[DriveSend] Warning: File may be incomplete: {file_path}")
# Still try to upload, but warn
# Initialize Drive service once
if _drive_service is None:
try:
from .gdrive_auth_manager import get_drive_service
_drive_service = get_drive_service(self.auth_method)
logger.info("[DriveSend] Drive service initialized")
except Exception as e:
logger.error(f"[DriveSend] Failed to initialize Drive service: {e}")
return
try:
file_to_upload = file_path
encrypted_file = None
# Encrypt if enabled (and file is not already .enc)
if self.enable_encryption and not file_path.lower().endswith('.enc'):
key = get_encryption_key()
if key:
result = encrypt_file(file_path, key=key)
if result['success']:
file_to_upload = result['output_path']
encrypted_file = file_to_upload
print(f"[DriveSend] Encrypted: {Path(file_path).name} -> {Path(file_to_upload).name}")
else:
print(f"[DriveSend] Encryption failed: {result.get('error')}")
else:
print("[DriveSend] Warning: Encryption enabled but no key found")
# Upload file with owner_email for ownership transfer
result = upload_file(
file_to_upload,
folder_id=self.folder_id,
service=self.service,
auth_method=self.auth_method,
owner_email=self.owner_email
)
if result['success']:
print(f"[DriveSend] ✓ Uploaded: {Path(file_to_upload).name}")
# Clean up encrypted file if requested
if encrypted_file and self.post_delete_enc:
while not _stop_queue_processor:
if not self.file_queue.empty():
file_path = self.file_queue.get()
if wait_for_complete_write(file_path):
try:
os.remove(encrypted_file)
print(f"[DriveSend] Deleted encrypted file: {Path(encrypted_file).name}")
result = upload_file(
file_path,
folder_id=self.folder_id,
service=_drive_service
)
if result.get('success'):
logger.info(f"[DriveSend] ✓ Uploaded: {os.path.basename(file_path)}")
# Delete .enc file after successful upload if requested
if self.delete_enc and file_path.lower().endswith('.enc'):
os.remove(file_path)
logger.info(f"[DriveSend] Deleted .enc file after upload: {file_path}")
else:
logger.error(f"[DriveSend] Upload failed: {result.get('error')}")
self.file_queue.put(file_path) # Requeue on failure
except Exception as e:
print(f"[DriveSend] Failed to delete encrypted file: {e}")
else:
print(f"[DriveSend] ✗ Upload failed: {result.get('error')}")
# Requeue on failure
self.upload_queue.put(file_path)
except Exception as e:
print(f"[DriveSend] Error processing {file_path}: {e}")
# Requeue on failure
self.upload_queue.put(file_path)
finally:
self.upload_queue.task_done()
logger.error(f"[DriveSend] Upload failed: {e}")
self.file_queue.put(file_path) # Requeue on failure
time.sleep(0.1) # Small delay to avoid CPU overload
logger.info("[DriveSend] Upload queue processor stopped")
threading.Thread(target=process_queue, daemon=True).start()
def start_monitoring(watch_folder, folder_id, auth_method='oauth', enable_encryption=False,
delete_enc=False, subfolder_monitor=True):
"""
Start monitoring a folder for new files to upload.
def start(self):
"""Start monitoring the directory."""
global _stop_queue_processor
if self.running:
print("[DriveSend] Monitor already running")
return
if not self.watch_dir.exists():
print(f"[DriveSend] Watch directory does not exist: {self.watch_dir}")
return
self.running = True
_stop_queue_processor = False # Reset stop signal
# Start upload worker thread
self.upload_thread = threading.Thread(target=self._upload_worker, daemon=True)
self.upload_thread.start()
# Set up file system observer
handler = UploadHandler(self.upload_queue, self.recursive, self.enable_encryption)
self.observer = Observer()
self.observer.schedule(handler, str(self.watch_dir), recursive=self.recursive)
self.observer.start()
print(f"[DriveSend] Started monitoring: {self.watch_dir}")
if self.recursive:
print("[DriveSend] Recursive monitoring enabled")
if self.enable_encryption:
print("[DriveSend] Encryption enabled")
if self.owner_email:
print(f"[DriveSend] Ownership will transfer to: {self.owner_email}")
elif self.auth_method == 'service_account':
print("[DriveSend] WARNING: No owner_email set - uploads may fail due to quota!")
Args:
watch_folder: Directory to monitor
folder_id: Google Drive folder ID
auth_method: 'oauth' or 'service_account'
enable_encryption: If True, only upload .enc files (created by separate encrypt watcher)
delete_enc: Delete .enc files after successful upload
subfolder_monitor: Monitor subfolders recursively
"""
global watcher_observer, watcher_handler, encryption_enabled, _stop_queue_processor, _drive_service
def stop(self):
"""Stop monitoring the directory."""
global _stop_queue_processor
if not self.running:
return
self.running = False
_stop_queue_processor = True # Signal queue processor to stop
if self.observer:
self.observer.stop()
self.observer.join(timeout=5)
self.observer = None
if self.upload_thread:
self.upload_thread.join(timeout=5)
self.upload_thread = None
print("[DriveSend] Stopped monitoring")
encryption_enabled = enable_encryption
_stop_queue_processor = False # Reset stop signal when starting
_drive_service = None # Reset service to force re-auth
if watcher_observer and watcher_observer.is_alive():
logger.info("[DriveSend] Restarting monitor to update settings.")
watcher_observer.stop()
watcher_observer.join()
watcher_handler = NewFileHandler(folder_id, auth_method, delete_enc)
watcher_observer = Observer()
watcher_observer.schedule(watcher_handler, watch_folder, recursive=subfolder_monitor)
watcher_observer.start()
logger.info(f"[DriveSend] Now watching: {watch_folder} (Subfolder_Monitor: {'Yes' if subfolder_monitor else 'No'})")
logger.info(f"[DriveSend] Upload target: Google Drive folder {folder_id}")
logger.info(f"[DriveSend] Encryption mode: {'Yes - uploading .enc files only' if encryption_enabled else 'No - uploading images directly'}")
def keep_alive():
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
watcher_observer.stop()
watcher_observer.join()
threading.Thread(target=keep_alive, daemon=True).start()
def stop_monitoring():
"""Stop the upload monitor."""
global watcher_observer, _drive_service
def is_running(self):
"""Check if the monitor is running."""
return self.running
# Global monitor instance
_monitor = None
def get_monitor():
"""Get the global monitor instance."""
global _monitor
return _monitor
stop_queue_processor()
if watcher_observer and watcher_observer.is_alive():
watcher_observer.stop()
watcher_observer.join()
logger.info("[DriveSend] Upload monitor stopped")
_drive_service = None # Clear cached service
# Legacy function names for compatibility
def start_monitor(**kwargs):
"""Start a new global monitor instance."""
global _monitor
"""Alias for start_monitoring."""
# Map new param names to old function
watch_dir = kwargs.pop('watch_dir', kwargs.pop('watch_folder', None))
recursive = kwargs.pop('recursive', kwargs.pop('subfolder_monitor', True))
post_delete_enc = kwargs.pop('post_delete_enc', kwargs.pop('delete_enc', False))
if _monitor and _monitor.is_running():
_monitor.stop()
_monitor = OutputMonitor(**kwargs)
_monitor.start()
return _monitor
return start_monitoring(
watch_folder=watch_dir,
subfolder_monitor=recursive,
delete_enc=post_delete_enc,
**kwargs
)
def stop_monitor():
"""Stop the global monitor instance."""
global _monitor
"""Alias for stop_monitoring."""
return stop_monitoring()
def get_monitor():
"""Get the current watcher observer."""
global watcher_observer
if _monitor:
_monitor.stop()
_monitor = None
class MonitorWrapper:
def is_running(self):
return watcher_observer and watcher_observer.is_alive()
return MonitorWrapper()
+14
View File
@@ -0,0 +1,14 @@
[project]
name = "comfyui-drivesend-node"
description = "Automatically upload ComfyUI output files to Google Drive with optional encryption. Supports OAuth and service accounts, real-time monitoring, cloud setups (RunPod), and includes cross-platform decryption scripts."
version = "1.0.0"
license = { file = "LICENSE" }
dependencies = ["requests", "python-dotenv", "watchdog", "cryptography", "google-auth", "google-auth-oauthlib", "google-api-python-client"]
[project.urls]
Repository = "https://github.com/machinepainting/ComfyUI_DriveSendNode"
[tool.comfy]
PublisherId = "machinepainting"
DisplayName = "DriveSend Node"
Icon = ""