From a2b773bb4980909112136b1f12908d70637649d4 Mon Sep 17 00:00:00 2001 From: kjqwer <2990346238@qq.com> Date: Mon, 17 Nov 2025 07:31:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=9B=BD=E9=99=85=E5=8C=96?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=AE=89=E5=85=A8=E9=97=AE=E9=A2=98?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=E8=8B=B1=E6=96=87=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- locales/en-US/main.json | 4 ++ locales/en-US/nodeDefs.json | 59 ++++++++++++++++++++++ locales/zh-CN/main.json | 4 ++ locales/zh-CN/nodeDefs.json | 59 ++++++++++++++++++++++ src/SmartSaveImage/core/path_utils.py | 30 ++++++++--- src/SmartSaveImage/nodes/__init__.py | 4 +- src/SmartSaveImage/nodes/folder_manager.py | 40 +++++++-------- src/SmartSaveImage/nodes/image_saver.py | 40 +++++++-------- src/SmartSaveImage/utils/validators.py | 49 +++++++++++++++++- 10 files changed, 240 insertions(+), 51 deletions(-) create mode 100644 locales/en-US/main.json create mode 100644 locales/en-US/nodeDefs.json create mode 100644 locales/zh-CN/main.json create mode 100644 locales/zh-CN/nodeDefs.json diff --git a/README.md b/README.md index 835777e..5b9e9dc 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ pip install -r requirements.txt ### 文件命名选项 - **文件名前缀**:自定义前缀 - **添加时间戳**:在文件名中包含时间 -- **添加计数器**:批量保存时的序号 +- **添加计数器**:多张图片时的序号 - **计数器设置**:起始值和位数 ## 🔧 高级功能 diff --git a/locales/en-US/main.json b/locales/en-US/main.json new file mode 100644 index 0000000..987a0a0 --- /dev/null +++ b/locales/en-US/main.json @@ -0,0 +1,4 @@ +{ + "SmartFolderManager": "Smart Folder Manager", + "SmartImageSaver": "Smart Image Saver" +} diff --git a/locales/en-US/nodeDefs.json b/locales/en-US/nodeDefs.json new file mode 100644 index 0000000..6461a7c --- /dev/null +++ b/locales/en-US/nodeDefs.json @@ -0,0 +1,59 @@ +{ + "SmartFolderManager": { + "title": "Smart Folder Manager", + "inputs": { + "images": "Input images for extracting size info and passing to save node", + "base_folder": "Base folder path, can be relative or absolute path", + "create_subfolders": "Whether to automatically create subfolders", + "enable_date_folder": "Whether to create date folder", + "enable_model_folder": "Whether to create model folder", + "enable_seed_folder": "Whether to create seed folder", + "enable_prompt_folder": "Whether to create prompt folder", + "enable_custom_folder": "Whether to use custom folder", + "date_format": "Date format: yyyy=year MM=month dd=day hh=hour mm=minute ss=second", + "include_time": "Whether to include time in date", + "model_source": "Model info source: auto=from metadata/external input, manual=manual selection", + "manual_model_name": "Manual model selection (only effective when model_source=manual)", + "model_input": "Input from model loader node (only effective when model_source=auto)", + "seed": "Seed value (manual input or connect external node)", + "positive_prompt": "Positive prompt (manual input or connect external node)", + "negative_prompt": "Negative prompt (manual input or connect external node)", + "custom_subfolder": "Custom subfolder name", + "model_short_name": "Use model short name (remove extension)", + "prompt_max_length": "Maximum length for prompt folder name", + "sanitize_names": "Clean illegal characters in filenames" + }, + "outputs": { + "images": "Passed image data", + "folder_path": "Generated folder path", + "metadata_json": "Extracted metadata JSON" + } + }, + "SmartImageSaver": { + "title": "Smart Image Saver", + "inputs": { + "images": "Images to save (usually from SmartFolderManager)", + "folder_path": "Save path (from SmartFolderManager)", + "metadata_json": "Metadata JSON (from SmartFolderManager)", + "filename_prefix": "Filename prefix", + "file_format": "Image format", + "preview_mode": "Save mode", + "add_timestamp": "Add timestamp to filename", + "add_counter": "Add counter (for multiple images)", + "counter_start": "Counter start value", + "counter_padding": "Counter padding (zero-fill)", + "jpeg_quality": "JPEG quality (1-100)", + "webp_quality": "WebP quality (1-100)", + "webp_lossless": "WebP lossless compression", + "png_compression": "PNG compression level (0-9)", + "embed_metadata": "Embed metadata into image", + "embed_workflow": "Embed workflow information", + "overwrite_existing": "Overwrite existing files", + "create_backup": "Create backup for overwritten files", + "optimize_size": "Optimize file size" + }, + "outputs": { + "images": "Saved images" + } + } +} diff --git a/locales/zh-CN/main.json b/locales/zh-CN/main.json new file mode 100644 index 0000000..ce64186 --- /dev/null +++ b/locales/zh-CN/main.json @@ -0,0 +1,4 @@ +{ + "SmartFolderManager": "智能文件夹管理", + "SmartImageSaver": "智能图片保存" +} diff --git a/locales/zh-CN/nodeDefs.json b/locales/zh-CN/nodeDefs.json new file mode 100644 index 0000000..310fcc4 --- /dev/null +++ b/locales/zh-CN/nodeDefs.json @@ -0,0 +1,59 @@ +{ + "SmartFolderManager": { + "title": "智能文件夹管理", + "inputs": { + "images": "输入图片,用于提取尺寸信息并传递给保存节点", + "base_folder": "基础文件夹路径,可以是相对路径或绝对路径", + "create_subfolders": "是否自动创建子文件夹", + "enable_date_folder": "是否创建日期文件夹", + "enable_model_folder": "是否创建模型文件夹", + "enable_seed_folder": "是否创建种子文件夹", + "enable_prompt_folder": "是否创建提示词文件夹", + "enable_custom_folder": "是否使用自定义文件夹", + "date_format": "日期格式: yyyy=年 MM=月 dd=日 hh=时 mm=分 ss=秒", + "include_time": "是否在日期中包含时间", + "model_source": "模型信息来源:auto=从元数据/外部输入获取,manual=手动选择", + "manual_model_name": "手动选择模型(仅在model_source=manual时生效)", + "model_input": "从模型加载器节点输入(仅在model_source=auto时生效)", + "seed": "种子值(手动输入或连接外部节点)", + "positive_prompt": "正向提示词(手动输入或连接外部节点)", + "negative_prompt": "负向提示词(手动输入或连接外部节点)", + "custom_subfolder": "自定义子文件夹名称", + "model_short_name": "使用模型短名称(去除扩展名)", + "prompt_max_length": "提示词文件夹名最大长度", + "sanitize_names": "清理文件名中的非法字符" + }, + "outputs": { + "images": "传递的图片数据", + "folder_path": "生成的文件夹路径", + "metadata_json": "提取的元数据JSON" + } + }, + "SmartImageSaver": { + "title": "智能图片保存", + "inputs": { + "images": "要保存的图片(通常来自SmartFolderManager)", + "folder_path": "保存路径(来自SmartFolderManager)", + "metadata_json": "元数据JSON(来自SmartFolderManager)", + "filename_prefix": "文件名前缀", + "file_format": "图片格式", + "preview_mode": "保存模式", + "add_timestamp": "在文件名中添加时间戳", + "add_counter": "添加计数器(多张图片时)", + "counter_start": "计数器起始值", + "counter_padding": "计数器位数(补零)", + "jpeg_quality": "JPEG质量(1-100)", + "webp_quality": "WebP质量(1-100)", + "webp_lossless": "WebP无损压缩", + "png_compression": "PNG压缩级别(0-9)", + "embed_metadata": "嵌入元数据到图片", + "embed_workflow": "嵌入工作流信息", + "overwrite_existing": "覆盖已存在的文件", + "create_backup": "为覆盖的文件创建备份", + "optimize_size": "优化文件大小" + }, + "outputs": { + "images": "保存的图片" + } + } +} diff --git a/src/SmartSaveImage/core/path_utils.py b/src/SmartSaveImage/core/path_utils.py index d016de5..079a312 100644 --- a/src/SmartSaveImage/core/path_utils.py +++ b/src/SmartSaveImage/core/path_utils.py @@ -24,12 +24,19 @@ class PathManager: } def sanitize_filename(self, name: str, max_length: int = 100) -> str: - """清理文件名,移除非法字符""" + """安全地清理文件名,移除非法字符和路径遍历攻击""" if not name: return "untitled" name = str(name) + # 使用os.path.basename确保只获取文件名部分,防止路径遍历 + name = os.path.basename(name) + + # 如果basename后为空(比如输入是路径分隔符),使用默认名称 + if not name or name in ('.', '..'): + return "untitled" + # 移除或替换非法字符 name = re.sub(self.illegal_chars, '_', name) @@ -41,6 +48,16 @@ class PathManager: # 移除首尾的下划线和点 name = name.strip('_.') + # 防止Windows保留名称 + reserved_names = [ + 'CON', 'PRN', 'AUX', 'NUL', + 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9' + ] + name_upper = name.upper() + if name_upper in reserved_names or name_upper.split('.')[0] in reserved_names: + name = f"_{name}" + # 限制长度 if len(name) > max_length: name = name[:max_length].rstrip('_.') @@ -143,13 +160,14 @@ class PathManager: return [seg for seg in path_segments if seg] def resolve_base_path(self, base_folder: str, default_output_dir: str) -> str: - """解析基础路径""" + """安全地解析基础路径""" + from ..utils.validators import InputValidator + if not base_folder or base_folder.lower() in ["", "output", "."]: return default_output_dir - elif os.path.isabs(base_folder): - return base_folder - else: - return os.path.join(default_output_dir, base_folder) + + # 使用安全的路径连接方法 + return InputValidator.secure_path_join(base_folder, "") def build_full_path(self, base_folder: str, structure_mode: str, metadata: Dict[str, Any], user_inputs: Dict[str, Any], diff --git a/src/SmartSaveImage/nodes/__init__.py b/src/SmartSaveImage/nodes/__init__.py index d46c403..9e8fa0e 100644 --- a/src/SmartSaveImage/nodes/__init__.py +++ b/src/SmartSaveImage/nodes/__init__.py @@ -10,8 +10,8 @@ NODE_CLASS_MAPPINGS = { } NODE_DISPLAY_NAME_MAPPINGS = { - "SmartFolderManager": "智能文件夹管理", - "SmartImageSaver": "智能图片保存", + "SmartFolderManager": "SmartFolderManager", + "SmartImageSaver": "SmartImageSaver", } __all__ = [ diff --git a/src/SmartSaveImage/nodes/folder_manager.py b/src/SmartSaveImage/nodes/folder_manager.py index 1e8d80b..fef2d69 100644 --- a/src/SmartSaveImage/nodes/folder_manager.py +++ b/src/SmartSaveImage/nodes/folder_manager.py @@ -24,103 +24,103 @@ class SmartFolderManager: def INPUT_TYPES(cls): return { "required": { - "images": ("IMAGE", {"tooltip": "输入图片,用于提取尺寸信息并传递给保存节点"}), + "images": ("IMAGE", {"tooltip": "Input images for extracting size info and passing to save node"}), "base_folder": ("STRING", { "default": "output", "multiline": False, - "tooltip": "基础文件夹路径,可以是相对路径或绝对路径" + "tooltip": "Base folder path, can be relative or absolute path" }), "create_subfolders": ("BOOLEAN", { "default": True, - "tooltip": "是否自动创建子文件夹" + "tooltip": "Whether to automatically create subfolders" }), }, "optional": { # 文件夹层级开关 "enable_date_folder": ("BOOLEAN", { "default": True, - "tooltip": "是否创建日期文件夹" + "tooltip": "Whether to create date folder" }), "enable_model_folder": ("BOOLEAN", { "default": True, - "tooltip": "是否创建模型文件夹" + "tooltip": "Whether to create model folder" }), "enable_seed_folder": ("BOOLEAN", { "default": True, - "tooltip": "是否创建种子文件夹" + "tooltip": "Whether to create seed folder" }), "enable_prompt_folder": ("BOOLEAN", { "default": False, - "tooltip": "是否创建提示词文件夹" + "tooltip": "Whether to create prompt folder" }), "enable_custom_folder": ("BOOLEAN", { "default": False, - "tooltip": "是否使用自定义文件夹" + "tooltip": "Whether to use custom folder" }), # 日期相关 "date_format": ("STRING", { "default": "yyyy-MM-dd", "multiline": False, - "tooltip": "日期格式: yyyy年 MM月 dd日 hh时 mm分 ss秒" + "tooltip": "Date format: yyyy=year MM=month dd=day hh=hour mm=minute ss=second" }), "include_time": ("BOOLEAN", { "default": False, - "tooltip": "是否在日期中包含时间" + "tooltip": "Whether to include time in date" }), # 模型信息来源选择 "model_source": (["auto", "manual"], { "default": "auto", - "tooltip": "模型信息来源:auto=从元数据/外部输入获取,manual=手动选择" + "tooltip": "Model info source: auto=from metadata/external input, manual=manual selection" }), "manual_model_name": (folder_paths.get_filename_list("checkpoints"), { - "tooltip": "手动选择模型(仅在model_source=manual时生效)" + "tooltip": "Manual model selection (only effective when model_source=manual)" }), "model_input": ("MODEL", { - "tooltip": "从模型加载器节点输入(仅在model_source=auto时生效)" + "tooltip": "Input from model loader node (only effective when model_source=auto)" }), # 种子信息 "seed": ("STRING", { "default": "0", "multiline": False, - "tooltip": "种子值(手动输入或连接外部节点)" + "tooltip": "Seed value (manual input or connect external node)" }), # 提示词信息 "positive_prompt": ("STRING", { "default": "", "multiline": True, - "tooltip": "正向提示词(手动输入或连接外部节点)" + "tooltip": "Positive prompt (manual input or connect external node)" }), "negative_prompt": ("STRING", { "default": "", "multiline": True, - "tooltip": "负向提示词(手动输入或连接外部节点)" + "tooltip": "Negative prompt (manual input or connect external node)" }), # 自定义路径 "custom_subfolder": ("STRING", { "default": "", "multiline": False, - "tooltip": "自定义子文件夹名称" + "tooltip": "Custom subfolder name" }), # 显示选项 "model_short_name": ("BOOLEAN", { "default": True, - "tooltip": "使用模型短名称(去除扩展名)" + "tooltip": "Use model short name (remove extension)" }), "prompt_max_length": ("INT", { "default": 50, "min": 10, "max": 200, - "tooltip": "提示词文件夹名最大长度" + "tooltip": "Maximum length for prompt folder name" }), "sanitize_names": ("BOOLEAN", { "default": True, - "tooltip": "清理文件名中的非法字符" + "tooltip": "Clean illegal characters in filenames" }), }, "hidden": { diff --git a/src/SmartSaveImage/nodes/image_saver.py b/src/SmartSaveImage/nodes/image_saver.py index e67a03b..34f90b5 100644 --- a/src/SmartSaveImage/nodes/image_saver.py +++ b/src/SmartSaveImage/nodes/image_saver.py @@ -28,52 +28,52 @@ class SmartImageSaver: def INPUT_TYPES(cls): return { "required": { - "images": ("IMAGE", {"tooltip": "要保存的图片(通常来自SmartFolderManager)"}), + "images": ("IMAGE", {"tooltip": "Images to save (usually from SmartFolderManager)"}), "folder_path": ("STRING", { "default": "", "multiline": False, - "tooltip": "保存路径(来自SmartFolderManager)" + "tooltip": "Save path (from SmartFolderManager)" }), "metadata_json": ("STRING", { "default": "", "multiline": True, - "tooltip": "元数据JSON(来自SmartFolderManager)" + "tooltip": "Metadata JSON (from SmartFolderManager)" }), "filename_prefix": ("STRING", { "default": "image", "multiline": False, - "tooltip": "文件名前缀" + "tooltip": "Filename prefix" }), "file_format": (["png", "jpeg", "webp", "bmp", "tiff"], { "default": "png", - "tooltip": "图片格式" + "tooltip": "Image format" }), "preview_mode": (["save_and_preview", "preview_only", "save_only"], { "default": "save_and_preview", - "tooltip": "保存模式" + "tooltip": "Save mode" }), }, "optional": { # 文件名选项 "add_timestamp": ("BOOLEAN", { "default": False, - "tooltip": "在文件名中添加时间戳" + "tooltip": "Add timestamp to filename" }), "add_counter": ("BOOLEAN", { "default": True, - "tooltip": "添加计数器(批量保存时)" + "tooltip": "Add counter (for multiple images)" }), "counter_start": ("INT", { "default": 1, "min": 0, "max": 99999, - "tooltip": "计数器起始值" + "tooltip": "Counter start value" }), "counter_padding": ("INT", { "default": 4, "min": 1, "max": 10, - "tooltip": "计数器位数(补零)" + "tooltip": "Counter padding (zero-fill)" }), # 图片质量选项 @@ -81,47 +81,47 @@ class SmartImageSaver: "default": 95, "min": 1, "max": 100, - "tooltip": "JPEG质量(1-100)" + "tooltip": "JPEG quality (1-100)" }), "webp_quality": ("INT", { "default": 90, "min": 1, "max": 100, - "tooltip": "WebP质量(1-100)" + "tooltip": "WebP quality (1-100)" }), "webp_lossless": ("BOOLEAN", { "default": False, - "tooltip": "WebP无损压缩" + "tooltip": "WebP lossless compression" }), "png_compression": ("INT", { "default": 6, "min": 0, "max": 9, - "tooltip": "PNG压缩级别(0-9)" + "tooltip": "PNG compression level (0-9)" }), # 元数据选项 "embed_metadata": ("BOOLEAN", { "default": True, - "tooltip": "嵌入元数据到图片" + "tooltip": "Embed metadata into image" }), "embed_workflow": ("BOOLEAN", { - "default": False, - "tooltip": "嵌入工作流信息" + "default": True, + "tooltip": "Embed workflow information" }), # 高级选项 "overwrite_existing": ("BOOLEAN", { "default": False, - "tooltip": "覆盖已存在的文件" + "tooltip": "Overwrite existing files" }), "create_backup": ("BOOLEAN", { "default": False, - "tooltip": "为覆盖的文件创建备份" + "tooltip": "Create backup for overwritten files" }), "optimize_size": ("BOOLEAN", { "default": False, - "tooltip": "优化文件大小" + "tooltip": "Optimize file size" }), }, "hidden": { diff --git a/src/SmartSaveImage/utils/validators.py b/src/SmartSaveImage/utils/validators.py index c3bd450..a9e0a1a 100644 --- a/src/SmartSaveImage/utils/validators.py +++ b/src/SmartSaveImage/utils/validators.py @@ -9,8 +9,8 @@ class InputValidator: """输入验证器""" @staticmethod - def validate_folder_path(path: str) -> bool: - """验证文件夹路径是否有效""" + def validate_folder_path(path: str, allowed_base_paths: list = None) -> bool: + """验证文件夹路径是否有效且安全""" if not path: return False @@ -23,8 +23,53 @@ class InputValidator: if re.search(illegal_chars, path): return False + # 检查路径遍历攻击 + if '..' in path or path.startswith('/') or '~' in path: + return False + return True + @staticmethod + def secure_path_join(base_path: str, user_path: str = "") -> str: + """安全地连接路径,防止路径遍历攻击 - 使用GitHub建议的方法""" + import folder_paths + + # 获取ComfyUI的输出目录作为安全基础路径 + safe_base = folder_paths.get_output_directory() + + try: + # 使用realpath解析安全基础路径 + resolved_safe_base = os.path.realpath(safe_base) + + # 处理base_path + if os.path.isabs(base_path): + # 绝对路径:直接解析 + candidate_path = os.path.realpath(base_path) + else: + # 相对路径:相对于安全基础路径 + candidate_path = os.path.realpath(os.path.join(safe_base, base_path)) + + # 如果有用户路径,继续连接 + if user_path: + candidate_path = os.path.realpath(os.path.join(candidate_path, user_path)) + + # 使用commonpath确保路径在安全范围内 + try: + common = os.path.commonpath([resolved_safe_base, candidate_path]) + # 检查公共路径是否就是安全基础路径 + if os.path.realpath(common) == resolved_safe_base: + return candidate_path + else: + # 路径逃逸,返回安全基础路径 + return resolved_safe_base + except ValueError: + # commonpath失败(通常是不同驱动器),返回安全基础路径 + return resolved_safe_base + + except (OSError, ValueError) as e: + # 任何路径操作失败,返回安全基础路径 + return safe_base + @staticmethod def validate_filename(filename: str) -> bool: """验证文件名是否有效"""