Merge branch 'claude/brave-napier'

This commit is contained in:
JasonHoku
2026-02-16 17:36:30 -08:00
4 changed files with 83 additions and 159 deletions
+5 -5
View File
@@ -12,8 +12,10 @@ are hidden from ComfyUI Manager. **All contributors must follow these rules:**
only, never called at runtime.
4. **No code obfuscation** — No encoded strings, multi-statement tricks, or undefined
variable patterns.
5. **No custom file-serving endpoints** — Use ComfyUI's built-in `WEB_DIRECTORY` mechanism
for serving JS. Never create custom routes that serve arbitrary files from disk.
5. **No custom file-serving endpoints** — Use ComfyUI's built-in `WEB_DIRECTORY` for JS and
the built-in `/view` endpoint for images. Never use `web.FileResponse` or create custom
routes that serve files from disk. For external images, create symlinks into the output
directory and use `/view?filename=X&type=output&subfolder=Y`.
6. **Path containment on all filesystem operations** — Every endpoint that touches the
filesystem must validate that resolved paths stay within the expected base directory
(e.g., `output/benchmarks/`). Use `os.path.realpath()` + `startswith()` checks.
@@ -139,9 +141,7 @@ All routes registered in `__init__.py` via `PromptServer.instance.routes`.
| POST | `/config_tester/save_manifest` | Full merge save `{session_name, manifest}` |
| POST | `/config_tester/get_session_html` | Generate dashboard HTML on-demand `{session_name, node_id}` |
| POST | `/config_tester/export_favorites` | Export favorited images `{session_name, pack_metadata, organize_by_prompt, organize_by_lora}` |
| POST | `/config_tester/scan_directory` | Scan external dir for images `{directory_path, session_name}` |
| GET | `/config_tester/view_external` | Serve whitelisted external images `?path=...` |
| POST | `/config_tester/whitelist_directory` | Re-whitelist dir after restart `{directory_path}` |
| POST | `/config_tester/scan_directory` | Scan external dir, create symlinks, use built-in `/view` `{directory_path, session_name}` |
---
+63 -125
View File
@@ -15,9 +15,6 @@ from .json_text_node import SmartJSONTextNode
from .metadata_packer import pack_metadata_into_image
from .directory_scanner import scan_directory_for_images
# Security whitelist for external image serving (populated by scan_directory route)
_whitelisted_directories = set()
# --- PATH SECURITY HELPERS ---
def _get_benchmarks_base():
@@ -427,18 +424,16 @@ async def export_favorites(request):
file_path = item.get("file", "")
# Parse filename from URL format: /view?filename=img_123.webp&type=output&subfolder=benchmarks/Session/images
if file_path.startswith("/config_tester/view_external?"):
# External directory image - parse absolute path from query param
parsed_url = urllib.parse.urlparse(file_path)
url_params = urllib.parse.parse_qs(parsed_url.query)
source_path = url_params.get("path", [""])[0]
filename = os.path.basename(source_path)
elif file_path.startswith("/view?"):
# Extract filename from URL
if file_path.startswith("/view?"):
# Extract filename and subfolder from URL
parsed_url = urllib.parse.urlparse(file_path)
url_params = urllib.parse.parse_qs(parsed_url.query)
filename = url_params.get("filename", [""])[0]
source_path = os.path.join(images_dir, filename)
subfolder = url_params.get("subfolder", [""])[0]
if subfolder:
source_path = os.path.join(folder_paths.get_output_directory(), subfolder, filename)
else:
source_path = os.path.join(images_dir, filename)
elif file_path.startswith("./images/"):
# Relative path format
filename = file_path[9:] # Remove ./images/
@@ -528,6 +523,8 @@ async def scan_directory_route(request):
"""
Scan an external directory for images, extract PNG metadata,
and generate a manifest compatible with the dashboard.
Creates symlinks in the output directory so ComfyUI's built-in
/view endpoint can serve the images (no custom file serving needed).
"""
try:
data = await request.json()
@@ -559,16 +556,33 @@ async def scan_directory_route(request):
print(f"[DirScanner] Scanning directory: {directory_path}")
print(f"[DirScanner] Session name: {session_name}")
# Prepare the symlink directory within the output folder
# so ComfyUI's built-in /view endpoint can serve the images
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.json_response({"error": "Forbidden: path outside benchmarks directory"}, status=403)
link_dir = os.path.join(base_dir, "external_images")
os.makedirs(link_dir, exist_ok=True)
# The subfolder path for /view URLs (relative to output dir)
view_subfolder = f"benchmarks/{session_name}/external_images"
# Run the scan in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
items, stats = await loop.run_in_executor(
None, scan_directory_for_images, directory_path
None, scan_directory_for_images, directory_path, 5000, view_subfolder
)
from_manifest = stats.get("from_manifest", 0)
manifest_info = f", {from_manifest} from existing manifest" if from_manifest > 0 else ""
print(f"[DirScanner] Found {stats['total']} images ({stats['with_metadata']} with metadata, {stats['skipped']} skipped{manifest_info})")
# Create symlinks (or copy on Windows if symlinks not available)
# for each image so /view can serve them
_create_image_links(directory_path, link_dir, items)
# Build manifest
manifest = {
"session_name": session_name,
@@ -582,12 +596,6 @@ async def scan_directory_route(request):
}
# Save manifest to benchmarks session directory
base_dir = os.path.join(folder_paths.get_output_directory(), "benchmarks", session_name)
if not _is_path_within(base_dir, _get_benchmarks_base()):
return web.json_response({"error": "Forbidden: path outside benchmarks directory"}, status=403)
os.makedirs(base_dir, exist_ok=True)
manifest_path = os.path.join(base_dir, "manifest.json")
# If manifest already exists, preserve user tags (favorited/rejected/notes)
@@ -616,16 +624,7 @@ async def scan_directory_route(request):
with open(manifest_path, "w") as f:
json.dump(manifest, f, indent=2)
# Whitelist the directory for the view_external route
real_dir = os.path.realpath(directory_path)
_whitelisted_directories.add(real_dir)
# Also whitelist images/ subdirectory if it exists (generated sessions store images there)
images_subdir = os.path.realpath(os.path.join(directory_path, "images"))
if os.path.isdir(images_subdir):
_whitelisted_directories.add(images_subdir)
print(f"[DirScanner] Whitelisted directory: {real_dir} (+ images/)")
else:
print(f"[DirScanner] Whitelisted directory: {real_dir}")
print(f"[DirScanner] Created image links in: {link_dir}")
from_manifest = stats.get("from_manifest", 0)
return web.json_response({
@@ -646,109 +645,48 @@ async def scan_directory_route(request):
return web.json_response({"error": str(e)}, status=500)
# =============================================================================
# API: SERVE EXTERNAL IMAGES (Security-Whitelisted)
# =============================================================================
_IMAGE_CONTENT_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".bmp": "image/bmp",
".gif": "image/gif",
}
@server.PromptServer.instance.routes.get("/config_tester/view_external")
async def view_external_image(request):
def _create_image_links(source_dir, link_dir, items):
"""
Serve image files from whitelisted directories.
Only directories registered via scan_directory are allowed.
Create symlinks (or copies on Windows) from source images into
the output directory so ComfyUI's built-in /view endpoint can serve them.
"""
try:
encoded_path = request.query.get("path", "")
if not encoded_path:
return web.Response(status=400, text="Missing path parameter")
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
file_path = urllib.parse.unquote(encoded_path)
# Collect all source files referenced by items
filenames = set()
for item in items:
src = item.get("source_file")
if src:
filenames.add(src)
# Security: reject directory traversal attempts
if ".." in file_path:
return web.Response(status=403, text="Forbidden: directory traversal")
# Scan source directory and subdirectories for matching files
scan_dirs = [source_dir]
images_subdir = os.path.join(source_dir, "images")
if os.path.isdir(images_subdir):
scan_dirs.append(images_subdir)
# Resolve to real path
real_path = os.path.realpath(file_path)
for scan_dir in scan_dirs:
try:
for entry in os.scandir(scan_dir):
if not entry.is_file():
continue
ext = os.path.splitext(entry.name)[1].lower()
if ext not in IMAGE_EXTENSIONS:
continue
if entry.name not in filenames:
continue
# Security: check file extension is an image
ext = os.path.splitext(real_path)[1].lower()
if ext not in _IMAGE_CONTENT_TYPES:
return web.Response(status=403, text="Forbidden: not an image file")
link_path = os.path.join(link_dir, entry.name)
if os.path.exists(link_path):
continue
# Security: check the file's directory is whitelisted
real_dir = os.path.dirname(real_path)
is_allowed = any(
real_dir == wl_dir or real_dir.startswith(wl_dir + os.sep)
for wl_dir in _whitelisted_directories
)
if not is_allowed:
return web.Response(status=403, text="Forbidden: directory not whitelisted")
if not os.path.isfile(real_path):
return web.Response(status=404, text="File not found")
content_type = _IMAGE_CONTENT_TYPES.get(ext, "application/octet-stream")
return web.FileResponse(
real_path,
headers={
"Content-Type": content_type,
"Cache-Control": "public, max-age=3600",
}
)
except Exception as e:
return web.Response(status=500, text=str(e))
# =============================================================================
# API: RE-WHITELIST DIRECTORY (for loading previously scanned sessions)
# =============================================================================
@server.PromptServer.instance.routes.post("/config_tester/whitelist_directory")
async def whitelist_directory_route(request):
"""
Re-whitelist a directory for serving images after server restart.
Called automatically when loading a scan-type session.
Only image files (checked by extension) can be served from whitelisted dirs.
"""
try:
data = await request.json()
directory_path = data.get("directory_path", "").strip()
if not directory_path:
return web.Response(status=400, text="Missing directory_path")
directory_path = os.path.normpath(directory_path)
if not os.path.isabs(directory_path):
return web.Response(status=400, text="Path must be absolute")
if not os.path.isdir(directory_path):
return web.Response(status=404, text="Directory not found")
# Cap whitelist size to prevent unbounded growth
if len(_whitelisted_directories) >= 50:
return web.Response(status=429, text="Too many whitelisted directories (max 50)")
real_dir = os.path.realpath(directory_path)
_whitelisted_directories.add(real_dir)
print(f"[DirScanner] Re-whitelisted directory: {real_dir}")
return web.Response(status=200, text="Whitelisted")
except Exception as e:
return web.Response(status=500, text=str(e))
try:
os.symlink(entry.path, link_path)
except (OSError, NotImplementedError):
# Windows may need admin privileges for symlinks; fall back to copy
shutil.copy2(entry.path, link_path)
except PermissionError:
print(f"[DirScanner] Warning: Permission denied scanning {scan_dir}")
# =============================================================================
+15 -19
View File
@@ -8,7 +8,6 @@ import re
import json
import time
import random
import urllib.parse
from PIL import Image
from .metadata_packer import extract_metadata_from_image
@@ -248,21 +247,16 @@ def parse_a1111_parameters(parameters_text):
def _extract_filename_from_url(file_url):
"""
Extract the image filename from a manifest item's file URL.
Handles both generated URLs (/view?filename=xxx&...) and external URLs (/config_tester/view_external?path=...).
Handles /view?filename=xxx&... URLs (both generated and external scan).
"""
if not file_url:
return None
# Generated session format: /view?filename=img_xxx.webp&type=output&subfolder=...
# Standard ComfyUI format: /view?filename=img_xxx.webp&type=output&subfolder=...
match = re.search(r'filename=([^&]+)', file_url)
if match:
return urllib.parse.unquote(match.group(1))
# External scan format: /config_tester/view_external?path=...
match = re.search(r'path=([^&]+)', file_url)
if match:
decoded = urllib.parse.unquote(match.group(1))
return os.path.basename(decoded)
from urllib.parse import unquote
return unquote(match.group(1))
return None
@@ -325,13 +319,15 @@ def _load_existing_manifest_items(directory_path):
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
def scan_directory_for_images(directory_path, max_items=5000):
def scan_directory_for_images(directory_path, max_items=5000, view_subfolder=""):
"""
Scan a directory for image files and build manifest-compatible items.
Args:
directory_path: Absolute path to the directory to scan
max_items: Maximum number of images to process
view_subfolder: Subfolder path for ComfyUI's /view endpoint
(e.g. "benchmarks/session/external_images")
Returns:
tuple: (items_list, stats_dict)
@@ -377,9 +373,9 @@ def scan_directory_for_images(directory_path, max_items=5000):
# If we have manifest data for this image, use it (much richer metadata)
if filename in manifest_items:
item = manifest_items[filename].copy()
# Rewrite file URL to use the external view endpoint
encoded_path = urllib.parse.quote(file_path, safe="")
item["file"] = f"/config_tester/view_external?path={encoded_path}"
# Use ComfyUI's built-in /view endpoint with subfolder
from urllib.parse import quote
item["file"] = f"/view?filename={quote(filename, safe='')}&type=output&subfolder={quote(view_subfolder, safe='/')}"
item["source_file"] = filename
# Ensure it has an ID
if "id" not in item:
@@ -390,7 +386,7 @@ def scan_directory_for_images(directory_path, max_items=5000):
# No manifest entry — fall through to normal image processing
try:
item = _process_single_image(file_path, directory_path)
item = _process_single_image(file_path, directory_path, view_subfolder)
if item:
items.append(item)
except Exception as e:
@@ -409,7 +405,7 @@ def scan_directory_for_images(directory_path, max_items=5000):
return items, stats
def _process_single_image(file_path, directory_path):
def _process_single_image(file_path, directory_path, view_subfolder=""):
"""
Process a single image file into a manifest-compatible item dict.
"""
@@ -418,9 +414,9 @@ def _process_single_image(file_path, directory_path):
# Generate unique ID (matching existing manifest pattern)
ts = int(time.time() * 100000) + random.randint(0, 1000)
# Build the external view URL
encoded_path = urllib.parse.quote(file_path, safe="")
file_url = f"/config_tester/view_external?path={encoded_path}"
# Build URL using ComfyUI's built-in /view endpoint
from urllib.parse import quote
file_url = f"/view?filename={quote(filename, safe='')}&type=output&subfolder={quote(view_subfolder, safe='/')}"
# Get image dimensions from PIL
try:
-10
View File
@@ -91,16 +91,6 @@ async function loadSession() {
activeData = fullManifest.items || [];
meta = fullManifest.meta || {};
// Auto-whitelist source directory for scan-type sessions
// (images won't load after server restart without this)
if (meta.scan_type === 'directory' && meta.source_directory) {
fetch('/config_tester/whitelist_directory', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ directory_path: meta.source_directory })
}).catch(e => console.warn('[Load] Could not whitelist directory:', e));
}
console.log(`[Load] 📦 activeData now has ${activeData.length} items`);
// 5. Reset indices