1866 lines
67 KiB
JavaScript
1866 lines
67 KiB
JavaScript
/**
|
||
* ComfyUI Workflow Protector - 增强版 + 工作流加密
|
||
* 提供多层保护机制防止未授权导出或复制
|
||
* 支持工作流文件加密,防止未授权使用
|
||
*/
|
||
|
||
import { app } from "../../scripts/app.js";
|
||
import { api } from "../../scripts/api.js";
|
||
|
||
// ==================== 全局状态 ====================
|
||
|
||
let currentToken = null;
|
||
let tokenExpiry = 0;
|
||
let protectionStatus = { has_password: false, enabled: false, protection_level: 'strict' };
|
||
let encryptionPassword = null; // 缓存的加密密码
|
||
let isDecryptingWorkflow = false; // 是否正在处理加密工作流解密
|
||
|
||
// ==================== 加密检测与操作 ====================
|
||
|
||
const MAGIC_HEADER = "COMFYUI_PROTECTED_WORKFLOW_V1";
|
||
|
||
// 检查是否是加密的工作流
|
||
function isEncryptedWorkflow(data) {
|
||
return data && data._protected === MAGIC_HEADER;
|
||
}
|
||
|
||
// 调用后端加密API
|
||
async function encryptWorkflow(workflow, password) {
|
||
try {
|
||
const response = await fetch('/api/workflow_protector/encrypt', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workflow, password })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
return { success: true, encrypted: result.encrypted };
|
||
} else {
|
||
return { success: false, error: result.message };
|
||
}
|
||
} catch (e) {
|
||
return { success: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// 调用后端解密API
|
||
async function decryptWorkflow(encryptedWorkflow, password) {
|
||
try {
|
||
const response = await fetch('/api/workflow_protector/decrypt', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ workflow: encryptedWorkflow, password })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
return { success: true, workflow: result.workflow };
|
||
} else {
|
||
return { success: false, error: result.message };
|
||
}
|
||
} catch (e) {
|
||
return { success: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
// ==================== 加密密码输入对话框 ====================
|
||
|
||
function showEncryptionDialog(mode = 'decrypt', filename = '') {
|
||
return new Promise((resolve) => {
|
||
// 移除可能存在的旧对话框
|
||
const existingOverlay = document.querySelector('.wp-encrypt-overlay');
|
||
if (existingOverlay) existingOverlay.remove();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'wp-overlay wp-encrypt-overlay';
|
||
|
||
const isDecrypt = mode === 'decrypt';
|
||
const title = isDecrypt ? '🔐 工作流已加密' : '🔒 加密保存工作流';
|
||
const subtitle = isDecrypt
|
||
? `文件: ${filename || '未知'}\n请输入密码以解密此工作流`
|
||
: '设置加密密码保护您的工作流\n加密后文件只能在本机使用';
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'wp-dialog';
|
||
dialog.style.minWidth = '400px';
|
||
dialog.innerHTML = `
|
||
<div class="wp-title">
|
||
<span>${title}</span>
|
||
</div>
|
||
<div style="color: #aaa; margin-bottom: 15px; white-space: pre-line; font-size: 13px;">${subtitle}</div>
|
||
<div class="wp-message wp-encrypt-error"></div>
|
||
<input type="password" class="wp-input wp-encrypt-pwd" placeholder="输入${isDecrypt ? '解密' : '加密'}密码" style="margin-bottom: 10px;">
|
||
${!isDecrypt ? '<input type="password" class="wp-input wp-encrypt-pwd-confirm" placeholder="确认密码" style="margin-bottom: 15px;">' : ''}
|
||
<div class="wp-buttons" style="display: flex; gap: 10px;">
|
||
<button class="wp-btn wp-btn-secondary wp-encrypt-cancel" style="flex: 1;">
|
||
${isDecrypt ? '取消' : '不加密保存'}
|
||
</button>
|
||
<button class="wp-btn wp-btn-primary wp-encrypt-confirm" style="flex: 1;">
|
||
${isDecrypt ? '解密并打开' : '加密保存'}
|
||
</button>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
// 使用class在对话框内部查找元素,避免ID冲突
|
||
const pwdInput = dialog.querySelector('.wp-encrypt-pwd');
|
||
const confirmInput = dialog.querySelector('.wp-encrypt-pwd-confirm');
|
||
const errorMsg = dialog.querySelector('.wp-encrypt-error');
|
||
const confirmBtn = dialog.querySelector('.wp-encrypt-confirm');
|
||
const cancelBtn = dialog.querySelector('.wp-encrypt-cancel');
|
||
|
||
if (pwdInput) pwdInput.focus();
|
||
|
||
// 清理函数 - 移除对话框和事件监听器
|
||
let resolved = false;
|
||
const cleanup = () => {
|
||
document.removeEventListener('keydown', handleEsc);
|
||
if (overlay.parentNode) {
|
||
overlay.parentNode.removeChild(overlay);
|
||
}
|
||
};
|
||
|
||
const doResolve = (result) => {
|
||
if (resolved) return;
|
||
resolved = true;
|
||
cleanup();
|
||
resolve(result);
|
||
};
|
||
|
||
// ESC事件处理器
|
||
const handleEsc = (e) => {
|
||
if (e.key === 'Escape') {
|
||
doResolve({ password: null, cancelled: true, skipEncryption: !isDecrypt });
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handleEsc);
|
||
|
||
const showError = (msg) => {
|
||
if (errorMsg) {
|
||
errorMsg.textContent = msg;
|
||
errorMsg.classList.add('error');
|
||
setTimeout(() => errorMsg.classList.remove('error'), 3000);
|
||
}
|
||
};
|
||
|
||
if (confirmBtn) {
|
||
confirmBtn.onclick = () => {
|
||
const pwd = pwdInput ? pwdInput.value : '';
|
||
|
||
if (!pwd) {
|
||
showError('请输入密码');
|
||
return;
|
||
}
|
||
|
||
if (!isDecrypt && confirmInput) {
|
||
const confirmPwd = confirmInput.value;
|
||
if (pwd !== confirmPwd) {
|
||
showError('两次密码不一致');
|
||
return;
|
||
}
|
||
if (pwd.length < 4) {
|
||
showError('密码至少4位');
|
||
return;
|
||
}
|
||
}
|
||
|
||
doResolve({ password: pwd, cancelled: false, skipEncryption: false });
|
||
};
|
||
}
|
||
|
||
if (cancelBtn) {
|
||
cancelBtn.onclick = () => {
|
||
doResolve({ password: null, cancelled: true, skipEncryption: !isDecrypt });
|
||
};
|
||
}
|
||
|
||
if (pwdInput) {
|
||
pwdInput.onkeypress = (e) => {
|
||
if (e.key === 'Enter') {
|
||
if (!isDecrypt && confirmInput) {
|
||
confirmInput.focus();
|
||
} else if (confirmBtn) {
|
||
confirmBtn.click();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
if (confirmInput) {
|
||
confirmInput.onkeypress = (e) => {
|
||
if (e.key === 'Enter' && confirmBtn) confirmBtn.click();
|
||
};
|
||
}
|
||
|
||
// 点击遮罩关闭
|
||
overlay.onclick = (e) => {
|
||
if (e.target === overlay) {
|
||
doResolve({ password: null, cancelled: true, skipEncryption: !isDecrypt });
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// ==================== 样式定义 ====================
|
||
|
||
const styles = `
|
||
.wp-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.85);
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 999999;
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
.wp-dialog {
|
||
background: linear-gradient(145deg, #2d2d2d, #1a1a1a);
|
||
border-radius: 16px;
|
||
padding: 28px;
|
||
min-width: 360px;
|
||
max-width: 450px;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255,255,255,0.1);
|
||
}
|
||
.wp-title {
|
||
color: #fff;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.wp-title-icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
}
|
||
.wp-input {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
border: 2px solid #404040;
|
||
border-radius: 10px;
|
||
background: #1a1a1a;
|
||
color: #fff;
|
||
font-size: 15px;
|
||
margin-bottom: 16px;
|
||
box-sizing: border-box;
|
||
transition: all 0.2s;
|
||
}
|
||
.wp-input:focus {
|
||
outline: none;
|
||
border-color: #667eea;
|
||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
|
||
}
|
||
.wp-input::placeholder {
|
||
color: #666;
|
||
}
|
||
.wp-buttons {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: flex-end;
|
||
margin-top: 8px;
|
||
}
|
||
.wp-btn {
|
||
padding: 10px 24px;
|
||
border-radius: 10px;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: all 0.2s;
|
||
}
|
||
.wp-btn-primary {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: #fff;
|
||
}
|
||
.wp-btn-primary:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
.wp-btn-secondary {
|
||
background: #404040;
|
||
color: #fff;
|
||
}
|
||
.wp-btn-secondary:hover {
|
||
background: #505050;
|
||
}
|
||
.wp-btn-danger {
|
||
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
|
||
color: #fff;
|
||
}
|
||
.wp-btn-danger:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 12px rgba(245, 87, 108, 0.4);
|
||
}
|
||
.wp-message {
|
||
padding: 10px 14px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
margin-bottom: 16px;
|
||
display: none;
|
||
}
|
||
.wp-message.error {
|
||
display: block;
|
||
background: rgba(245, 87, 108, 0.15);
|
||
border: 1px solid rgba(245, 87, 108, 0.3);
|
||
color: #f5576c;
|
||
}
|
||
.wp-message.success {
|
||
display: block;
|
||
background: rgba(46, 204, 113, 0.15);
|
||
border: 1px solid rgba(46, 204, 113, 0.3);
|
||
color: #2ecc71;
|
||
}
|
||
.wp-label {
|
||
color: #aaa;
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
}
|
||
.wp-section {
|
||
margin-bottom: 20px;
|
||
padding-bottom: 20px;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.wp-section:last-of-type {
|
||
border-bottom: none;
|
||
margin-bottom: 0;
|
||
padding-bottom: 0;
|
||
}
|
||
.wp-status-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
background: #1a1a1a;
|
||
border-radius: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.wp-status-dot {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.wp-status-dot.active {
|
||
background: #2ecc71;
|
||
box-shadow: 0 0 8px rgba(46, 204, 113, 0.5);
|
||
}
|
||
.wp-status-dot.inactive {
|
||
background: #e74c3c;
|
||
box-shadow: 0 0 8px rgba(231, 76, 60, 0.5);
|
||
}
|
||
.wp-status-dot.warning {
|
||
background: #f39c12;
|
||
box-shadow: 0 0 8px rgba(243, 156, 18, 0.5);
|
||
}
|
||
.wp-status-text {
|
||
color: #ccc;
|
||
font-size: 13px;
|
||
}
|
||
.wp-status-text strong {
|
||
color: #fff;
|
||
}
|
||
.wp-level-select {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.wp-level-btn {
|
||
flex: 1;
|
||
padding: 10px;
|
||
border: 2px solid #404040;
|
||
border-radius: 8px;
|
||
background: #1a1a1a;
|
||
color: #aaa;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
}
|
||
.wp-level-btn:hover {
|
||
border-color: #667eea;
|
||
color: #fff;
|
||
}
|
||
.wp-level-btn.active {
|
||
border-color: #667eea;
|
||
background: rgba(102, 126, 234, 0.15);
|
||
color: #667eea;
|
||
}
|
||
.wp-tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
margin-bottom: 20px;
|
||
background: #1a1a1a;
|
||
padding: 4px;
|
||
border-radius: 10px;
|
||
}
|
||
.wp-tab {
|
||
flex: 1;
|
||
padding: 10px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: #888;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
}
|
||
.wp-tab:hover {
|
||
color: #fff;
|
||
}
|
||
.wp-tab.active {
|
||
background: #333;
|
||
color: #fff;
|
||
}
|
||
.wp-tab-content {
|
||
display: none;
|
||
}
|
||
.wp-tab-content.active {
|
||
display: block;
|
||
}
|
||
.wp-log-container {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
background: #0d0d0d;
|
||
border-radius: 8px;
|
||
padding: 10px;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
color: #888;
|
||
}
|
||
.wp-log-entry {
|
||
padding: 4px 0;
|
||
border-bottom: 1px solid #1a1a1a;
|
||
}
|
||
.wp-log-entry:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.wp-authorized-badge {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%);
|
||
color: #fff;
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
z-index: 99998;
|
||
box-shadow: 0 4px 12px rgba(46, 204, 113, 0.4);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.wp-authorized-badge:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
.wp-lock-screen {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.95);
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
align-items: center;
|
||
z-index: 999999;
|
||
}
|
||
.wp-lock-icon {
|
||
font-size: 64px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.wp-lock-title {
|
||
color: #fff;
|
||
font-size: 24px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
}
|
||
.wp-lock-subtitle {
|
||
color: #888;
|
||
font-size: 14px;
|
||
margin-bottom: 32px;
|
||
}
|
||
`;
|
||
|
||
// 注入样式
|
||
const styleEl = document.createElement('style');
|
||
styleEl.textContent = styles;
|
||
document.head.appendChild(styleEl);
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
async function fetchStatus() {
|
||
try {
|
||
const response = await fetch('/api/workflow_protector/status');
|
||
const newStatus = await response.json();
|
||
protectionStatus = newStatus;
|
||
return protectionStatus;
|
||
} catch (e) {
|
||
console.error('[Workflow Protector] 获取状态失败:', e);
|
||
return protectionStatus;
|
||
}
|
||
}
|
||
|
||
function isAuthorized() {
|
||
// 如果正在解密工作流,临时授权
|
||
if (isDecryptingWorkflow) {
|
||
return true;
|
||
}
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) {
|
||
return true;
|
||
}
|
||
return currentToken && Date.now() < tokenExpiry;
|
||
}
|
||
|
||
function setToken(token, expiresIn) {
|
||
currentToken = token;
|
||
tokenExpiry = Date.now() + (expiresIn * 1000);
|
||
updateAuthBadge();
|
||
}
|
||
|
||
function clearToken() {
|
||
currentToken = null;
|
||
tokenExpiry = 0;
|
||
updateAuthBadge();
|
||
}
|
||
|
||
// ==================== 授权状态徽章 ====================
|
||
|
||
let authBadge = null;
|
||
|
||
function updateAuthBadge() {
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) {
|
||
if (authBadge) authBadge.remove();
|
||
return;
|
||
}
|
||
|
||
if (isAuthorized()) {
|
||
if (!authBadge) {
|
||
authBadge = document.createElement('div');
|
||
authBadge.className = 'wp-authorized-badge';
|
||
authBadge.onclick = () => showSettingsDialog();
|
||
document.body.appendChild(authBadge);
|
||
}
|
||
const remaining = Math.ceil((tokenExpiry - Date.now()) / 1000);
|
||
const mins = Math.floor(remaining / 60);
|
||
const secs = remaining % 60;
|
||
authBadge.innerHTML = `🔓 已授权 (${mins}:${secs.toString().padStart(2, '0')})`;
|
||
} else {
|
||
if (authBadge) {
|
||
authBadge.remove();
|
||
authBadge = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 定时更新徽章
|
||
setInterval(updateAuthBadge, 1000);
|
||
|
||
// ==================== 锁屏界面 ====================
|
||
|
||
let lockScreen = null;
|
||
|
||
function showLockScreen() {
|
||
if (lockScreen) return;
|
||
|
||
lockScreen = document.createElement('div');
|
||
lockScreen.className = 'wp-lock-screen';
|
||
lockScreen.innerHTML = `
|
||
<div class="wp-lock-icon">🔒</div>
|
||
<div class="wp-lock-title">工作流保护已启用</div>
|
||
<div class="wp-lock-subtitle">请输入密码以继续使用 ComfyUI</div>
|
||
<input type="password" class="wp-input" id="wp-lock-password" placeholder="输入保护密码" style="width: 280px;">
|
||
<div class="wp-message" id="wp-lock-error"></div>
|
||
<button class="wp-btn wp-btn-primary" id="wp-lock-submit" style="width: 280px; margin-top: 8px;">解锁</button>
|
||
`;
|
||
document.body.appendChild(lockScreen);
|
||
|
||
const input = document.getElementById('wp-lock-password');
|
||
const error = document.getElementById('wp-lock-error');
|
||
const submit = document.getElementById('wp-lock-submit');
|
||
|
||
input.focus();
|
||
|
||
const doUnlock = async () => {
|
||
const password = input.value;
|
||
try {
|
||
const response = await fetch('/api/workflow_protector/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
setToken(result.token, result.expires_in || 300);
|
||
lockScreen.remove();
|
||
lockScreen = null;
|
||
} else {
|
||
error.textContent = result.message;
|
||
error.classList.add('error');
|
||
input.value = '';
|
||
input.focus();
|
||
}
|
||
} catch (e) {
|
||
error.textContent = '验证失败: ' + e.message;
|
||
error.classList.add('error');
|
||
}
|
||
};
|
||
|
||
submit.onclick = doUnlock;
|
||
input.onkeypress = (e) => { if (e.key === 'Enter') doUnlock(); };
|
||
}
|
||
|
||
function hideLockScreen() {
|
||
if (lockScreen) {
|
||
lockScreen.remove();
|
||
lockScreen = null;
|
||
}
|
||
}
|
||
|
||
// ==================== 密码验证对话框 ====================
|
||
|
||
function showPasswordDialog(action) {
|
||
// 如果正在解密工作流,跳过API保护检查
|
||
if (isDecryptingWorkflow) {
|
||
console.log('[Workflow Protector] 正在解密工作流,跳过API保护');
|
||
return Promise.resolve(true);
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const actionTexts = {
|
||
'export': '导出/另存为',
|
||
'copy': '复制节点',
|
||
'cut': '剪切节点',
|
||
'duplicate': '复制/克隆节点',
|
||
'saveas': '另存为',
|
||
'template': '保存模板',
|
||
'save': '保存工作流',
|
||
'api': '访问API',
|
||
'general': '执行此操作'
|
||
};
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'wp-overlay';
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'wp-dialog';
|
||
dialog.innerHTML = `
|
||
<div class="wp-title">
|
||
<div class="wp-title-icon">🔐</div>
|
||
<span>需要授权</span>
|
||
</div>
|
||
<div class="wp-message" id="wp-verify-error"></div>
|
||
<div class="wp-label">请输入密码以${actionTexts[action] || action}:</div>
|
||
<input type="password" class="wp-input" id="wp-verify-password" placeholder="输入保护密码">
|
||
<div class="wp-buttons">
|
||
<button class="wp-btn wp-btn-secondary" id="wp-verify-cancel">取消</button>
|
||
<button class="wp-btn wp-btn-primary" id="wp-verify-confirm">验证</button>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
const input = document.getElementById('wp-verify-password');
|
||
const error = document.getElementById('wp-verify-error');
|
||
const confirmBtn = document.getElementById('wp-verify-confirm');
|
||
const cancelBtn = document.getElementById('wp-verify-cancel');
|
||
|
||
input.focus();
|
||
|
||
let cleaned = false;
|
||
const cleanup = () => {
|
||
if (cleaned) return;
|
||
cleaned = true;
|
||
if (overlay.parentNode) {
|
||
overlay.parentNode.removeChild(overlay);
|
||
}
|
||
};
|
||
|
||
const verify = async () => {
|
||
const password = input.value;
|
||
if (!password) {
|
||
error.textContent = '请输入密码';
|
||
error.classList.add('error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 直接使用原生fetch,避免被拦截
|
||
const response = await fetch('/api/workflow_protector/verify', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
setToken(result.token, result.expires_in || 300);
|
||
cleanup();
|
||
resolve(true);
|
||
} else {
|
||
error.textContent = result.message || '密码错误';
|
||
error.classList.add('error');
|
||
input.value = '';
|
||
input.focus();
|
||
}
|
||
} catch (e) {
|
||
console.error('[Workflow Protector] 验证请求失败:', e);
|
||
error.textContent = '验证失败: ' + e.message;
|
||
error.classList.add('error');
|
||
}
|
||
};
|
||
|
||
confirmBtn.onclick = verify;
|
||
cancelBtn.onclick = () => { cleanup(); resolve(false); };
|
||
input.onkeypress = (e) => { if (e.key === 'Enter') verify(); };
|
||
overlay.onclick = (e) => { if (e.target === overlay) { cleanup(); resolve(false); } };
|
||
});
|
||
}
|
||
|
||
// ==================== 设置对话框 ====================
|
||
|
||
async function showSettingsDialog() {
|
||
const status = await fetchStatus();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'wp-overlay';
|
||
|
||
const dialog = document.createElement('div');
|
||
dialog.className = 'wp-dialog';
|
||
dialog.style.minWidth = '420px';
|
||
|
||
const statusDot = status.has_password && status.enabled ? 'active' :
|
||
status.has_password ? 'warning' : 'inactive';
|
||
const statusText = status.has_password ?
|
||
(status.enabled ? '保护已启用' : '保护已禁用') : '未设置密码';
|
||
|
||
const levelNames = { strict: '严格', moderate: '中等', basic: '基础' };
|
||
|
||
dialog.innerHTML = `
|
||
<div class="wp-title">
|
||
<div class="wp-title-icon">🛡️</div>
|
||
<span>工作流保护设置</span>
|
||
</div>
|
||
|
||
<div class="wp-status-card">
|
||
<div class="wp-status-dot ${statusDot}"></div>
|
||
<div class="wp-status-text">
|
||
<strong>${statusText}</strong><br>
|
||
保护级别: ${levelNames[status.protection_level] || '严格'} |
|
||
活跃会话: ${status.active_sessions || 0}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wp-message" id="wp-settings-msg"></div>
|
||
|
||
${status.has_password ? `
|
||
<div class="wp-tabs">
|
||
<button class="wp-tab active" data-tab="password">密码管理</button>
|
||
<button class="wp-tab" data-tab="settings">保护设置</button>
|
||
<button class="wp-tab" data-tab="tools">工具</button>
|
||
<button class="wp-tab" data-tab="logs">访问日志</button>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="wp-tab-content active" id="tab-password">
|
||
<div class="wp-section">
|
||
<div class="wp-label">${status.has_password ? '修改密码' : '设置保护密码'}</div>
|
||
${status.has_password ? '<input type="password" class="wp-input" id="wp-old-pwd" placeholder="输入原密码">' : ''}
|
||
<input type="password" class="wp-input" id="wp-new-pwd" placeholder="输入新密码">
|
||
<input type="password" class="wp-input" id="wp-confirm-pwd" placeholder="确认新密码">
|
||
<button class="wp-btn wp-btn-primary" id="wp-set-pwd" style="width:100%">
|
||
${status.has_password ? '修改密码' : '设置密码'}
|
||
</button>
|
||
</div>
|
||
|
||
${status.has_password ? `
|
||
<div class="wp-section">
|
||
<div class="wp-label">清除密码保护</div>
|
||
<input type="password" class="wp-input" id="wp-clear-pwd" placeholder="输入当前密码确认">
|
||
<button class="wp-btn wp-btn-danger" id="wp-clear" style="width:100%">清除密码</button>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
${status.has_password ? `
|
||
<div class="wp-tab-content" id="tab-settings">
|
||
<div class="wp-section">
|
||
<div class="wp-label">保护开关</div>
|
||
<input type="password" class="wp-input" id="wp-toggle-pwd" placeholder="输入密码">
|
||
<div style="display:flex;gap:10px">
|
||
<button class="wp-btn ${status.enabled ? 'wp-btn-secondary' : 'wp-btn-primary'}" id="wp-enable" style="flex:1">
|
||
启用保护
|
||
</button>
|
||
<button class="wp-btn ${status.enabled ? 'wp-btn-danger' : 'wp-btn-secondary'}" id="wp-disable" style="flex:1">
|
||
禁用保护
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wp-section">
|
||
<div class="wp-label">保护级别</div>
|
||
<input type="password" class="wp-input" id="wp-level-pwd" placeholder="输入密码">
|
||
<div class="wp-level-select">
|
||
<button class="wp-level-btn ${status.protection_level === 'strict' ? 'active' : ''}" data-level="strict">
|
||
🔒 严格<br><small>保护所有API</small>
|
||
</button>
|
||
<button class="wp-level-btn ${status.protection_level === 'moderate' ? 'active' : ''}" data-level="moderate">
|
||
🔐 中等<br><small>保护敏感API</small>
|
||
</button>
|
||
<button class="wp-level-btn ${status.protection_level === 'basic' ? 'active' : ''}" data-level="basic">
|
||
🔓 基础<br><small>仅前端保护</small>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wp-tab-content" id="tab-logs">
|
||
<div class="wp-section">
|
||
<div class="wp-label">最近访问记录</div>
|
||
<div class="wp-log-container" id="wp-logs">加载中...</div>
|
||
<button class="wp-btn wp-btn-secondary" id="wp-refresh-logs" style="width:100%;margin-top:10px">刷新日志</button>
|
||
<button class="wp-btn wp-btn-danger" id="wp-clear-logs" style="width:100%;margin-top:8px">清除日志</button>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<div class="wp-buttons" style="margin-top:20px">
|
||
<button class="wp-btn wp-btn-secondary" id="wp-close">关闭</button>
|
||
</div>
|
||
`;
|
||
|
||
overlay.appendChild(dialog);
|
||
document.body.appendChild(overlay);
|
||
|
||
const msg = document.getElementById('wp-settings-msg');
|
||
|
||
const showMsg = (text, isError = true) => {
|
||
msg.textContent = text;
|
||
msg.className = `wp-message ${isError ? 'error' : 'success'}`;
|
||
setTimeout(() => msg.className = 'wp-message', 3000);
|
||
};
|
||
|
||
const cleanup = () => document.body.removeChild(overlay);
|
||
|
||
// 日志加载函数(提前定义,避免引用错误)
|
||
const loadLogs = async () => {
|
||
const container = document.getElementById('wp-logs');
|
||
if (!container) return;
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/logs', {
|
||
headers: { 'X-WP-Token': currentToken }
|
||
});
|
||
const result = await resp.json();
|
||
|
||
if (result.success && result.logs.length > 0) {
|
||
container.innerHTML = result.logs.reverse().map(log =>
|
||
`<div class="wp-log-entry">${log}</div>`
|
||
).join('');
|
||
} else {
|
||
container.innerHTML = '<div style="color:#666;text-align:center">暂无日志</div>';
|
||
}
|
||
} catch (e) {
|
||
container.innerHTML = '<div style="color:#f5576c">加载失败,请先验证授权</div>';
|
||
}
|
||
};
|
||
|
||
// 标签页切换
|
||
dialog.querySelectorAll('.wp-tab').forEach(tab => {
|
||
tab.onclick = () => {
|
||
dialog.querySelectorAll('.wp-tab').forEach(t => t.classList.remove('active'));
|
||
dialog.querySelectorAll('.wp-tab-content').forEach(c => c.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||
|
||
if (tab.dataset.tab === 'logs') loadLogs();
|
||
};
|
||
});
|
||
|
||
// 设置密码
|
||
document.getElementById('wp-set-pwd').onclick = async () => {
|
||
const oldPwd = document.getElementById('wp-old-pwd')?.value || '';
|
||
const newPwd = document.getElementById('wp-new-pwd').value;
|
||
const confirmPwd = document.getElementById('wp-confirm-pwd').value;
|
||
|
||
if (!newPwd) return showMsg('请输入新密码');
|
||
if (newPwd.length < 6) return showMsg('密码长度至少6位');
|
||
if (newPwd !== confirmPwd) return showMsg('两次密码不一致');
|
||
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/set_password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ old_password: oldPwd, new_password: newPwd })
|
||
});
|
||
|
||
const result = await resp.json();
|
||
|
||
if (result.success) {
|
||
showMsg(result.message, false);
|
||
clearToken();
|
||
|
||
// 等待后端处理完成
|
||
await new Promise(r => setTimeout(r, 300));
|
||
|
||
// 更新全局状态
|
||
protectionStatus = await fetchStatus();
|
||
|
||
setTimeout(() => {
|
||
cleanup();
|
||
showSettingsDialog();
|
||
}, 300);
|
||
} else {
|
||
showMsg(result.message);
|
||
}
|
||
} catch (e) {
|
||
console.error('[Workflow Protector] 设置密码异常:', e);
|
||
showMsg('操作失败: ' + e.message);
|
||
}
|
||
};
|
||
|
||
// 清除密码
|
||
if (status.has_password) {
|
||
document.getElementById('wp-clear').onclick = async () => {
|
||
const pwd = document.getElementById('wp-clear-pwd').value;
|
||
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/clear_password', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: pwd })
|
||
});
|
||
const result = await resp.json();
|
||
|
||
if (result.success) {
|
||
showMsg(result.message, false);
|
||
clearToken();
|
||
// 立即更新全局状态
|
||
protectionStatus = await fetchStatus();
|
||
console.log('[Workflow Protector] 密码已清除,当前状态:', protectionStatus);
|
||
setTimeout(() => { cleanup(); showSettingsDialog(); }, 800);
|
||
} else {
|
||
showMsg(result.message);
|
||
}
|
||
} catch (e) {
|
||
showMsg('操作失败: ' + e.message);
|
||
}
|
||
};
|
||
|
||
// 启用/禁用
|
||
document.getElementById('wp-enable').onclick = async () => {
|
||
const pwd = document.getElementById('wp-toggle-pwd').value;
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/toggle', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: pwd, enabled: true })
|
||
});
|
||
const result = await resp.json();
|
||
result.success ? (showMsg(result.message, false), setTimeout(() => { cleanup(); showSettingsDialog(); }, 1000)) : showMsg(result.message);
|
||
} catch (e) { showMsg('操作失败'); }
|
||
};
|
||
|
||
document.getElementById('wp-disable').onclick = async () => {
|
||
const pwd = document.getElementById('wp-toggle-pwd').value;
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/toggle', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: pwd, enabled: false })
|
||
});
|
||
const result = await resp.json();
|
||
result.success ? (showMsg(result.message, false), setTimeout(() => { cleanup(); showSettingsDialog(); }, 1000)) : showMsg(result.message);
|
||
} catch (e) { showMsg('操作失败'); }
|
||
};
|
||
|
||
// 保护级别
|
||
dialog.querySelectorAll('.wp-level-btn').forEach(btn => {
|
||
btn.onclick = async () => {
|
||
const pwd = document.getElementById('wp-level-pwd').value;
|
||
const level = btn.dataset.level;
|
||
|
||
try {
|
||
const resp = await fetch('/api/workflow_protector/set_level', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ password: pwd, level })
|
||
});
|
||
const result = await resp.json();
|
||
|
||
if (result.success) {
|
||
showMsg(result.message, false);
|
||
dialog.querySelectorAll('.wp-level-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
} else {
|
||
showMsg(result.message);
|
||
}
|
||
} catch (e) { showMsg('操作失败'); }
|
||
};
|
||
});
|
||
|
||
// 日志按钮绑定
|
||
document.getElementById('wp-refresh-logs').onclick = loadLogs;
|
||
document.getElementById('wp-clear-logs').onclick = async () => {
|
||
try {
|
||
await fetch('/api/workflow_protector/clear_logs', {
|
||
method: 'POST',
|
||
headers: { 'X-WP-Token': currentToken }
|
||
});
|
||
loadLogs();
|
||
} catch (e) { showMsg('清除失败'); }
|
||
};
|
||
}
|
||
|
||
// 关闭
|
||
document.getElementById('wp-close').onclick = cleanup;
|
||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||
}
|
||
|
||
// ==================== API拦截 ====================
|
||
|
||
// 注意:不再拦截API路径,因为会在页面加载时触发
|
||
// 保护通过前端操作拦截实现(键盘快捷键、右键菜单、文件保存)
|
||
|
||
// 保持原始fetch引用,用于插件自身的API调用
|
||
const originalFetch = window.fetch;
|
||
|
||
// ==================== 键盘和剪贴板拦截 ====================
|
||
|
||
// 需要保护的快捷键列表
|
||
const PROTECTED_KEYS = [
|
||
{ key: 'c', ctrl: true, action: 'copy', desc: '复制' },
|
||
{ key: 'x', ctrl: true, action: 'cut', desc: '剪切' },
|
||
{ key: 'd', ctrl: true, action: 'duplicate', desc: '复制节点' },
|
||
{ key: 'e', ctrl: true, shift: true, action: 'export', desc: '导出' },
|
||
{ key: 'e', ctrl: true, action: 'export', desc: '导出' },
|
||
{ key: 's', ctrl: true, shift: true, action: 'saveas', desc: '另存为' },
|
||
];
|
||
|
||
// 标记是否正在显示密码对话框
|
||
let isShowingPasswordDialog = false;
|
||
|
||
// 拦截键盘事件 - 使用缓存状态进行同步检查
|
||
document.addEventListener('keydown', (e) => {
|
||
// 使用缓存的状态进行同步检查(避免异步问题)
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) return;
|
||
if (isAuthorized()) return;
|
||
if (isShowingPasswordDialog) return;
|
||
|
||
// 检查是否匹配保护的快捷键
|
||
for (const pk of PROTECTED_KEYS) {
|
||
const ctrlMatch = pk.ctrl ? (e.ctrlKey || e.metaKey) : true;
|
||
const shiftMatch = pk.shift ? e.shiftKey : (pk.shift === false ? !e.shiftKey : true);
|
||
const keyMatch = e.key.toLowerCase() === pk.key;
|
||
|
||
if (ctrlMatch && shiftMatch && keyMatch) {
|
||
// 对于复制/剪切/复制节点,需要检查是否选中了节点
|
||
if (['copy', 'cut', 'duplicate'].includes(pk.action)) {
|
||
const hasSelectedNodes = app.canvas?.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0;
|
||
if (!hasSelectedNodes) continue;
|
||
}
|
||
|
||
// 立即阻止事件
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
e.stopPropagation();
|
||
|
||
// 异步显示密码对话框
|
||
isShowingPasswordDialog = true;
|
||
showPasswordDialog(pk.action).then(authorized => {
|
||
isShowingPasswordDialog = false;
|
||
if (authorized) {
|
||
// 授权后重新触发操作
|
||
console.log('[Workflow Protector] 授权成功,执行:', pk.action);
|
||
if (pk.action === 'copy') {
|
||
document.execCommand('copy');
|
||
} else if (pk.action === 'cut') {
|
||
document.execCommand('cut');
|
||
} else if (pk.action === 'duplicate' && app.canvas?.cloneSelection) {
|
||
app.canvas.cloneSelection();
|
||
}
|
||
}
|
||
});
|
||
|
||
return false;
|
||
}
|
||
}
|
||
}, true);
|
||
|
||
// 拦截 copy 和 cut 事件
|
||
['copy', 'cut'].forEach(eventType => {
|
||
document.addEventListener(eventType, (e) => {
|
||
// 使用缓存状态
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) return;
|
||
if (isAuthorized()) return;
|
||
|
||
// 检查是否选中了节点
|
||
const hasSelectedNodes = app.canvas?.selected_nodes && Object.keys(app.canvas.selected_nodes).length > 0;
|
||
if (!hasSelectedNodes) return;
|
||
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
}, true);
|
||
});
|
||
|
||
// ==================== 拖拽保护 ====================
|
||
|
||
// 拦截拖拽事件(防止拖拽节点到外部)
|
||
document.addEventListener('dragstart', (e) => {
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) return;
|
||
if (isAuthorized()) return;
|
||
|
||
// 检查是否在画布区域拖拽
|
||
const isCanvasArea = e.target.closest('canvas') || e.target.closest('.litegraph');
|
||
if (isCanvasArea) {
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
showPasswordDialog('export');
|
||
}
|
||
}, true);
|
||
|
||
// ==================== 右键菜单拦截 ====================
|
||
|
||
// 需要保护的菜单关键词
|
||
const PROTECTED_MENU_WORDS = [
|
||
// 英文
|
||
'export', 'copy', 'cut', 'clone', 'duplicate', 'clipboard',
|
||
'save as', 'save to', 'template', 'group', 'pack', 'share',
|
||
// 中文
|
||
'导出', '复制', '剪切', '克隆', '重复', '另存为', '另存',
|
||
'模板', '分组', '打包', '分享', '拷贝'
|
||
];
|
||
|
||
// 排除词(普通保存)
|
||
const EXCLUDED_WORDS = ['save workflow', '保存工作流', 'save project'];
|
||
|
||
document.addEventListener('contextmenu', () => {
|
||
setTimeout(async () => {
|
||
const status = await fetchStatus();
|
||
if (!status.has_password || !status.enabled) return;
|
||
|
||
// 查找所有可能的菜单项
|
||
const menuSelectors = [
|
||
'.litecontextmenu .litemenu-entry',
|
||
'.comfy-context-menu-item',
|
||
'[class*="context-menu"] [class*="item"]',
|
||
'[class*="menu"] [class*="item"]',
|
||
'[class*="dropdown"] [class*="item"]',
|
||
'[role="menuitem"]',
|
||
'.p-menuitem',
|
||
'.menu-item'
|
||
];
|
||
|
||
const menuItems = document.querySelectorAll(menuSelectors.join(', '));
|
||
|
||
menuItems.forEach(item => {
|
||
const text = (item.textContent || '').toLowerCase();
|
||
|
||
// 检查是否匹配保护词
|
||
const isProtectedAction = PROTECTED_MENU_WORDS.some(w => text.includes(w.toLowerCase()));
|
||
|
||
// 检查是否是排除词
|
||
const isExcluded = EXCLUDED_WORDS.some(w => text.includes(w.toLowerCase()));
|
||
|
||
// 特殊处理:只有"save"不保护,但"save as"要保护
|
||
const isSaveOnly = (text.includes('save') || text.includes('保存')) &&
|
||
!text.includes('save as') && !text.includes('另存') &&
|
||
!text.includes('save to') && !text.includes('template');
|
||
|
||
if (isProtectedAction && !isExcluded && !isSaveOnly) {
|
||
// 保存原始的onclick
|
||
const originalOnclick = item.onclick;
|
||
|
||
// 替换onclick
|
||
item.onclick = async (e) => {
|
||
if (!isAuthorized()) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
const authorized = await showPasswordDialog('export');
|
||
if (!authorized) {
|
||
return false;
|
||
}
|
||
}
|
||
// 授权后执行原始操作
|
||
if (originalOnclick) {
|
||
originalOnclick.call(item, e);
|
||
}
|
||
};
|
||
|
||
// 也添加click事件监听器(有些菜单可能用addEventListener而不是onclick)
|
||
item.addEventListener('click', async (e) => {
|
||
if (!isAuthorized()) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
await showPasswordDialog('export');
|
||
return false;
|
||
}
|
||
}, true);
|
||
}
|
||
});
|
||
}, 50);
|
||
}, true);
|
||
|
||
// 持续监听菜单(有些菜单是动态生成的)
|
||
setInterval(() => {
|
||
// 使用缓存状态
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) return;
|
||
if (isAuthorized()) return;
|
||
|
||
const visibleMenus = document.querySelectorAll('.litecontextmenu, [class*="context-menu"]:not([style*="display: none"])');
|
||
if (visibleMenus.length === 0) return;
|
||
|
||
visibleMenus.forEach(menu => {
|
||
if (menu._wpProtected) return;
|
||
menu._wpProtected = true;
|
||
|
||
menu.querySelectorAll('.litemenu-entry, [class*="item"]').forEach(item => {
|
||
const text = (item.textContent || '').toLowerCase();
|
||
const isProtected = PROTECTED_MENU_WORDS.some(w => text.includes(w.toLowerCase()));
|
||
const isSaveOnly = text.includes('save') && !text.includes('save as') && !text.includes('另存');
|
||
|
||
if (isProtected && !isSaveOnly && !item._wpHandler) {
|
||
item._wpHandler = true;
|
||
item.addEventListener('click', async (e) => {
|
||
if (!isAuthorized()) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
await showPasswordDialog('export');
|
||
return false;
|
||
}
|
||
}, true);
|
||
}
|
||
});
|
||
});
|
||
}, 200);
|
||
|
||
// ==================== 拦截ComfyUI原生导出功能 ====================
|
||
|
||
// 注意:导出拦截已整合到 interceptFileSave() 中,
|
||
// 不再在此处单独覆写 document.createElement,避免双重 async 包装导致下载失败
|
||
|
||
// 拦截 Blob URL 创建(工作流导出前的步骤)
|
||
const originalCreateObjectURL = URL.createObjectURL.bind(URL);
|
||
URL.createObjectURL = function(blob) {
|
||
const url = originalCreateObjectURL(blob);
|
||
|
||
// 检查是否是JSON类型的Blob(可能是工作流)
|
||
if (blob && (blob.type === 'application/json' || blob.type === 'text/json')) {
|
||
// 记录这个URL,可能是工作流
|
||
console.log('[Workflow Protector] 检测到JSON Blob创建');
|
||
}
|
||
|
||
return url;
|
||
};
|
||
|
||
// 拦截 saveAs / FileSaver
|
||
if (typeof saveAs !== 'undefined') {
|
||
const originalSaveAs = saveAs;
|
||
window.saveAs = function(blob, filename) {
|
||
// 同步快速路径:无保护时直接保存
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) {
|
||
return originalSaveAs(blob, filename);
|
||
}
|
||
if (isAuthorized()) {
|
||
return originalSaveAs(blob, filename);
|
||
}
|
||
if (filename && (filename.endsWith('.json') || filename.includes('workflow'))) {
|
||
(async () => {
|
||
const authorized = await showPasswordDialog('export');
|
||
if (!authorized) {
|
||
console.log('[Workflow Protector] saveAs 被阻止');
|
||
return;
|
||
}
|
||
originalSaveAs(blob, filename);
|
||
})();
|
||
} else {
|
||
return originalSaveAs(blob, filename);
|
||
}
|
||
};
|
||
}
|
||
|
||
// 监听并拦截动态创建的下载链接
|
||
const downloadObserver = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
mutation.addedNodes.forEach((node) => {
|
||
if (node.nodeType === 1 && node.tagName === 'A' && node.download) {
|
||
// 跳过bypass标记的链接
|
||
if (node._wpBypass) return;
|
||
|
||
const download = node.download || '';
|
||
if (download.endsWith('.json') || download.includes('workflow')) {
|
||
const originalClick = node.click.bind(node);
|
||
node.click = function() {
|
||
// 同步快速路径:无保护时直接点击
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) {
|
||
return originalClick();
|
||
}
|
||
if (isAuthorized()) {
|
||
return originalClick();
|
||
}
|
||
(async () => {
|
||
const authorized = await showPasswordDialog('export');
|
||
if (!authorized) return;
|
||
originalClick();
|
||
})();
|
||
};
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
downloadObserver.observe(document.body, { childList: true, subtree: true });
|
||
|
||
// ==================== 监听动态菜单 ====================
|
||
|
||
// 使用 MutationObserver 监听菜单创建
|
||
const menuObserver = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
mutation.addedNodes.forEach(async (node) => {
|
||
if (node.nodeType !== 1) return;
|
||
|
||
const status = await fetchStatus();
|
||
if (!status.has_password || !status.enabled) return;
|
||
|
||
// 查找所有可能的菜单项
|
||
const items = node.querySelectorAll ?
|
||
node.querySelectorAll('[class*="menu"] button, [class*="menu"] [role="menuitem"], .litemenu-entry, [class*="dropdown"] button, .p-menuitem-link') : [];
|
||
|
||
items.forEach(item => {
|
||
if (item._wpObserved) return;
|
||
item._wpObserved = true;
|
||
|
||
const text = (item.textContent || '').toLowerCase();
|
||
|
||
// 检查是否需要保护
|
||
const isProtected = PROTECTED_MENU_WORDS.some(w => text.includes(w.toLowerCase()));
|
||
const isSaveOnly = text.includes('save') && !text.includes('save as') && !text.includes('另存');
|
||
|
||
if (isProtected && !isSaveOnly) {
|
||
item.addEventListener('click', async (e) => {
|
||
if (!isAuthorized()) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
e.stopImmediatePropagation();
|
||
|
||
const authorized = await showPasswordDialog('export');
|
||
if (!authorized) {
|
||
return false;
|
||
}
|
||
}
|
||
}, true);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
menuObserver.observe(document.body, { childList: true, subtree: true });
|
||
|
||
// ==================== 拦截 LiteGraph 内部方法 ====================
|
||
|
||
// 等待 app 加载完成后拦截内部方法
|
||
const interceptLiteGraph = () => {
|
||
if (!app.canvas) {
|
||
setTimeout(interceptLiteGraph, 500);
|
||
return;
|
||
}
|
||
|
||
const canvas = app.canvas;
|
||
|
||
// 拦截 copyToClipboard
|
||
if (canvas.copyToClipboard) {
|
||
const originalCopy = canvas.copyToClipboard.bind(canvas);
|
||
canvas.copyToClipboard = async function(nodes) {
|
||
const status = await fetchStatus();
|
||
if (status.has_password && status.enabled && !isAuthorized()) {
|
||
const authorized = await showPasswordDialog('copy');
|
||
if (!authorized) return;
|
||
}
|
||
return originalCopy(nodes);
|
||
};
|
||
}
|
||
|
||
// 拦截 pasteFromClipboard(粘贴时检查来源)
|
||
// 注意:粘贴本身不需要保护,但可以记录
|
||
|
||
// 拦截 cloneSelection(Ctrl+D)
|
||
if (canvas.cloneSelection) {
|
||
const originalClone = canvas.cloneSelection.bind(canvas);
|
||
canvas.cloneSelection = async function() {
|
||
const status = await fetchStatus();
|
||
if (status.has_password && status.enabled && !isAuthorized()) {
|
||
const authorized = await showPasswordDialog('duplicate');
|
||
if (!authorized) return;
|
||
}
|
||
return originalClone();
|
||
};
|
||
}
|
||
|
||
// 拦截 graph.serialize(工作流序列化)
|
||
if (app.graph && app.graph.serialize) {
|
||
const originalSerialize = app.graph.serialize.bind(app.graph);
|
||
app.graph.serialize = function() {
|
||
// 标记序列化调用
|
||
const result = originalSerialize();
|
||
return result;
|
||
};
|
||
}
|
||
|
||
console.log('[Workflow Protector] LiteGraph 方法已拦截');
|
||
};
|
||
|
||
setTimeout(interceptLiteGraph, 1000);
|
||
|
||
// ==================== 拦截工作流保存和加载 ====================
|
||
|
||
// 保存时加密处理
|
||
async function handleSaveEncryption(workflow, filename) {
|
||
const status = await fetchStatus();
|
||
|
||
// 如果未启用保护,不加密
|
||
if (!status.has_password || !status.enabled) {
|
||
return { workflow, encrypted: false };
|
||
}
|
||
|
||
// 询问是否加密
|
||
const result = await showEncryptionDialog('encrypt', filename);
|
||
|
||
if (result.cancelled) {
|
||
if (result.skipEncryption) {
|
||
// 用户选择不加密,直接保存
|
||
return { workflow, encrypted: false };
|
||
}
|
||
return { workflow: null, cancelled: true };
|
||
}
|
||
|
||
if (result.password) {
|
||
// 调用后端API加密
|
||
const encryptResult = await encryptWorkflow(workflow, result.password);
|
||
|
||
if (encryptResult.success) {
|
||
console.log('[Workflow Protector] 工作流已加密');
|
||
return { workflow: encryptResult.encrypted, encrypted: true };
|
||
} else {
|
||
alert('加密失败: ' + encryptResult.error);
|
||
return { workflow, encrypted: false };
|
||
}
|
||
}
|
||
|
||
return { workflow, encrypted: false };
|
||
}
|
||
|
||
// 加载时解密处理
|
||
async function handleLoadDecryption(data, filename) {
|
||
if (!isEncryptedWorkflow(data)) {
|
||
return { workflow: data, encrypted: false };
|
||
}
|
||
|
||
console.log('[Workflow Protector] 检测到加密工作流');
|
||
|
||
// 设置解密中标志,防止触发其他保护机制
|
||
isDecryptingWorkflow = true;
|
||
|
||
try {
|
||
// 如果有缓存的密码,先尝试
|
||
if (encryptionPassword) {
|
||
const result = await decryptWorkflow(data, encryptionPassword);
|
||
if (result.success) {
|
||
console.log('[Workflow Protector] 使用缓存密码解密成功');
|
||
return { workflow: result.workflow, encrypted: true };
|
||
}
|
||
// 密码不对,清除缓存
|
||
encryptionPassword = null;
|
||
}
|
||
|
||
// 请求密码
|
||
const dialogResult = await showEncryptionDialog('decrypt', filename);
|
||
|
||
if (dialogResult.cancelled) {
|
||
return { workflow: null, cancelled: true, error: '用户取消解密' };
|
||
}
|
||
|
||
// 调用后端API解密
|
||
const result = await decryptWorkflow(data, dialogResult.password);
|
||
|
||
if (result.success) {
|
||
// 缓存密码
|
||
encryptionPassword = dialogResult.password;
|
||
console.log('[Workflow Protector] 工作流已解密');
|
||
return { workflow: result.workflow, encrypted: true };
|
||
} else {
|
||
alert('解密失败: ' + result.error);
|
||
return { workflow: null, cancelled: true, error: result.error };
|
||
}
|
||
} finally {
|
||
// 无论成功失败,都要清除标志
|
||
isDecryptingWorkflow = false;
|
||
}
|
||
}
|
||
|
||
// 拦截文件保存(核心逻辑)
|
||
function interceptFileSave() {
|
||
// 拦截下载链接的创建和点击
|
||
const originalCreateElement = document.createElement.bind(document);
|
||
|
||
document.createElement = function(tagName) {
|
||
const element = originalCreateElement(tagName);
|
||
|
||
if (tagName.toLowerCase() === 'a') {
|
||
const originalClick = element.click.bind(element);
|
||
|
||
element.click = function() {
|
||
// 如果标记了bypass,直接执行原始点击(用于加密后的下载链接)
|
||
if (this._wpBypass) {
|
||
return originalClick();
|
||
}
|
||
|
||
const download = this.download || '';
|
||
const href = this.href || '';
|
||
|
||
// 检查是否是工作流JSON文件下载
|
||
const isWorkflowFile = download.endsWith('.json') ||
|
||
download.includes('workflow') ||
|
||
download.includes('template') ||
|
||
href.includes('blob:');
|
||
const isWorkflowSave = (download.endsWith('.json') || download.includes('workflow')) && href.startsWith('blob:');
|
||
|
||
// ★ 关键修复:如果没有设置密码或保护未启用,同步执行原始点击
|
||
// 不走async路径,避免blob URL在异步等待期间被ComfyUI回收
|
||
if (!protectionStatus.has_password || !protectionStatus.enabled) {
|
||
return originalClick();
|
||
}
|
||
|
||
// 有密码保护的情况下,走async路径
|
||
const self = this;
|
||
(async () => {
|
||
// 导出保护检查:未授权时需要输入密码
|
||
if (isWorkflowFile && !isAuthorized()) {
|
||
const authorized = await showPasswordDialog('export');
|
||
if (!authorized) {
|
||
console.log('[Workflow Protector] 导出被阻止');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 加密处理(仅对工作流JSON文件)
|
||
if (isWorkflowSave) {
|
||
try {
|
||
// 获取Blob内容
|
||
const response = await fetch(href);
|
||
const text = await response.text();
|
||
let workflow;
|
||
|
||
try {
|
||
workflow = JSON.parse(text);
|
||
} catch (e) {
|
||
// 不是有效JSON,直接保存
|
||
originalClick();
|
||
return;
|
||
}
|
||
|
||
// 如果已经加密,直接保存
|
||
if (isEncryptedWorkflow(workflow)) {
|
||
originalClick();
|
||
return;
|
||
}
|
||
|
||
// 处理加密
|
||
const result = await handleSaveEncryption(workflow, download);
|
||
|
||
if (result.cancelled) {
|
||
console.log('[Workflow Protector] 保存已取消');
|
||
URL.revokeObjectURL(href);
|
||
return;
|
||
}
|
||
|
||
if (result.encrypted) {
|
||
// 创建新的加密Blob并下载
|
||
const jsonStr = JSON.stringify(result.workflow, null, 2);
|
||
const newBlob = new Blob([jsonStr], { type: 'application/json' });
|
||
const newUrl = URL.createObjectURL(newBlob);
|
||
|
||
const newLink = document.createElement('a');
|
||
newLink.href = newUrl;
|
||
newLink.download = download.replace('.json', '_encrypted.json');
|
||
newLink.style.display = 'none';
|
||
newLink._wpBypass = true; // 标记跳过拦截
|
||
|
||
document.body.appendChild(newLink);
|
||
newLink.click();
|
||
|
||
// 延迟清理,确保下载已开始
|
||
setTimeout(() => {
|
||
document.body.removeChild(newLink);
|
||
URL.revokeObjectURL(newUrl);
|
||
URL.revokeObjectURL(href);
|
||
}, 1000);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.error('[Workflow Protector] 保存处理错误:', e);
|
||
}
|
||
}
|
||
|
||
// 正常保存(授权后或不需要加密时)
|
||
originalClick();
|
||
})();
|
||
};
|
||
}
|
||
|
||
return element;
|
||
};
|
||
|
||
console.log('[Workflow Protector] 文件保存拦截已启用');
|
||
}
|
||
|
||
// 拦截工作流加载
|
||
function interceptWorkflowLoad() {
|
||
// 等待app加载
|
||
const setupLoadInterception = () => {
|
||
if (!app || !app.loadGraphData) {
|
||
setTimeout(setupLoadInterception, 500);
|
||
return;
|
||
}
|
||
|
||
// 拦截 loadGraphData
|
||
const originalLoadGraphData = app.loadGraphData.bind(app);
|
||
|
||
app.loadGraphData = async function(data, ...args) {
|
||
// 检查是否是加密工作流
|
||
if (isEncryptedWorkflow(data)) {
|
||
const result = await handleLoadDecryption(data, 'workflow');
|
||
|
||
if (result.cancelled || !result.workflow) {
|
||
console.log('[Workflow Protector] 加载已取消或解密失败');
|
||
return;
|
||
}
|
||
|
||
data = result.workflow;
|
||
}
|
||
|
||
return originalLoadGraphData(data, ...args);
|
||
};
|
||
|
||
console.log('[Workflow Protector] loadGraphData 拦截已启用');
|
||
};
|
||
|
||
setupLoadInterception();
|
||
|
||
// 拦截文件读取
|
||
const OriginalFileReader = FileReader;
|
||
|
||
window.FileReader = function() {
|
||
const reader = new OriginalFileReader();
|
||
const originalReadAsText = reader.readAsText.bind(reader);
|
||
|
||
reader.readAsText = function(blob, encoding) {
|
||
const originalOnload = reader.onload;
|
||
|
||
reader.onload = async function(e) {
|
||
let result = e.target.result;
|
||
|
||
try {
|
||
const data = JSON.parse(result);
|
||
|
||
if (isEncryptedWorkflow(data)) {
|
||
const decryptResult = await handleLoadDecryption(data, blob.name || 'workflow.json');
|
||
|
||
if (decryptResult.workflow) {
|
||
// 替换结果为解密后的数据
|
||
Object.defineProperty(e.target, 'result', {
|
||
value: JSON.stringify(decryptResult.workflow),
|
||
writable: false,
|
||
configurable: true
|
||
});
|
||
} else {
|
||
// 解密失败,阻止加载
|
||
console.log('[Workflow Protector] 解密失败或取消,阻止加载');
|
||
return;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
// 不是JSON或解析错误,继续正常处理
|
||
}
|
||
|
||
if (originalOnload) {
|
||
originalOnload.call(reader, e);
|
||
}
|
||
};
|
||
|
||
return originalReadAsText(blob, encoding);
|
||
};
|
||
|
||
return reader;
|
||
};
|
||
window.FileReader.prototype = OriginalFileReader.prototype;
|
||
|
||
console.log('[Workflow Protector] FileReader 拦截已启用');
|
||
|
||
// 拦截拖放
|
||
document.addEventListener('drop', async (e) => {
|
||
const files = e.dataTransfer?.files;
|
||
if (!files || files.length === 0) return;
|
||
|
||
for (const file of files) {
|
||
if (file.name.endsWith('.json')) {
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
|
||
if (isEncryptedWorkflow(data)) {
|
||
// 阻止默认处理
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const result = await handleLoadDecryption(data, file.name);
|
||
|
||
if (result.workflow && app.loadGraphData) {
|
||
app.loadGraphData(result.workflow);
|
||
}
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
// 忽略
|
||
}
|
||
}
|
||
}
|
||
}, true);
|
||
}
|
||
|
||
// ==================== 浮动按钮 ====================
|
||
|
||
function createFloatingButton() {
|
||
// 检查是否已存在
|
||
if (document.getElementById('wp-floating-btn')) return;
|
||
|
||
const btn = document.createElement('div');
|
||
btn.id = 'wp-floating-btn';
|
||
btn.innerHTML = '🛡️';
|
||
btn.title = '工作流保护设置';
|
||
btn.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
right: 0;
|
||
transform: translateY(-50%);
|
||
width: 40px;
|
||
height: 40px;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 8px 0 0 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
z-index: 99997;
|
||
box-shadow: -2px 0 10px rgba(0,0,0,0.3);
|
||
transition: all 0.3s;
|
||
`;
|
||
|
||
btn.onmouseenter = () => {
|
||
btn.style.width = '120px';
|
||
btn.innerHTML = '🛡️ 保护设置';
|
||
btn.style.fontSize = '14px';
|
||
btn.style.paddingLeft = '10px';
|
||
btn.style.justifyContent = 'flex-start';
|
||
};
|
||
|
||
btn.onmouseleave = () => {
|
||
btn.style.width = '40px';
|
||
btn.innerHTML = '🛡️';
|
||
btn.style.fontSize = '20px';
|
||
btn.style.paddingLeft = '0';
|
||
btn.style.justifyContent = 'center';
|
||
};
|
||
|
||
btn.onclick = showSettingsDialog;
|
||
document.body.appendChild(btn);
|
||
|
||
console.log('[Workflow Protector] 浮动按钮已创建');
|
||
}
|
||
|
||
// ==================== 注册扩展 ====================
|
||
|
||
app.registerExtension({
|
||
name: "Workflow.Protector.Enhanced",
|
||
|
||
async setup() {
|
||
// 获取初始状态
|
||
await fetchStatus();
|
||
|
||
// 创建浮动按钮
|
||
createFloatingButton();
|
||
|
||
// 启用文件保存拦截(加密功能)
|
||
interceptFileSave();
|
||
|
||
// 启用文件加载拦截(解密功能)
|
||
interceptWorkflowLoad();
|
||
|
||
// 同时尝试添加到传统菜单(兼容不同版本)
|
||
const menuSelectors = [
|
||
".comfy-menu",
|
||
".side-bar-menu",
|
||
"#comfy-menu",
|
||
".comfyui-menu"
|
||
];
|
||
|
||
for (const selector of menuSelectors) {
|
||
const menu = document.querySelector(selector);
|
||
if (menu) {
|
||
const btn = document.createElement("button");
|
||
btn.innerHTML = "🛡️ 工作流保护";
|
||
btn.style.cssText = "margin-top: 5px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; font-weight: 500; padding: 8px 12px; border-radius: 6px; cursor: pointer;";
|
||
btn.onclick = showSettingsDialog;
|
||
menu.appendChild(btn);
|
||
console.log('[Workflow Protector] 菜单按钮已添加到:', selector);
|
||
break;
|
||
}
|
||
}
|
||
|
||
console.log('[Workflow Protector] 已激活 - 全面保护 + 加密模式');
|
||
console.log('[Workflow Protector] 功能: 导出保护 / 复制保护 / 文件加密');
|
||
}
|
||
});
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
// 立即获取状态
|
||
fetchStatus().then(status => {
|
||
console.log('[Workflow Protector] 状态:', status.has_password ? (status.enabled ? '已启用' : '已禁用') : '未设置');
|
||
console.log('[Workflow Protector] 加密说明: 保存时可选加密,本机自动解密,其他电脑需要密码');
|
||
});
|
||
|
||
// 定期刷新状态(每30秒)
|
||
setInterval(() => {
|
||
fetchStatus().then(status => {
|
||
// 静默更新,不输出日志
|
||
});
|
||
}, 30000);
|