Files
Rohit MahtoandCommandCodeBot e43313b9f4 feat: password-protect file manager and terminal (0.6.1)
- New password_auth module: random token generated on first run, encrypted
  at rest under ~/.champdev (Fernet, cryptography dep), verify + 12h signed
  session cookie. CHAMPDEV_AUTH_PASSWORD env overrides the generated token
  and is what gets sent to telemetry.
- File Manager: write actions (upload/delete/rename/move/copy/mkdir) hidden
  behind an in-UI Unlock dialog and enforced server-side (401). While locked,
  browsing is clamped to the ComfyUI root instead of 403ing, with a lock
  banner. Unlock reveals write actions and allows browsing above the root.
- Terminal: websocket refuses to spawn a shell without auth; lock overlay
  with Unlock dialog, reconnects on success.
- Telemetry: token included in the ingest payload (server column exists).
- Frontend: shared champdev_auth.js in-UI password modal (window.prompt was
  silently blocked in embedded contexts).

Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-08-23 19:51:42 +05:30

191 lines
6.5 KiB
Python

import os
import folder_paths
from aiohttp import web
from server import PromptServer
from . import fm_core
from . import password_auth
routes = PromptServer.instance.routes
# Browse root while locked: the ComfyUI install directory. Everything above it
# (home dirs, /etc, ...) requires the password.
BROWSE_ROOT = getattr(folder_paths, "base_path", None) or folder_paths.get_output_directory()
def _thumb_cache_dir():
return os.path.join(folder_paths.get_temp_directory(), "champdev_fm_thumbs")
def _clamp_to_root(path):
"""While locked, pin a requested path inside BROWSE_ROOT instead of 403ing,
so the FM always shows files down to the ComfyUI root."""
real = fm_core.safe_realpath(path)
root = os.path.realpath(BROWSE_ROOT)
if real == root or real.startswith(root + os.sep):
return real, False
# Common path with root — move down into the deepest allowed ancestor.
common = os.path.commonpath([real, root])
if common == root:
return root, True
if common.startswith(root + os.sep):
return common, True
# Completely unrelated path → fall back to root itself.
return root, True
def _browse_denied():
return web.json_response({"error": "unlock to browse outside ComfyUI"}, status=403)
@routes.get("/champdev/fm/list")
async def fm_list(request):
try:
path = request.query.get("path") or folder_paths.get_output_directory()
clamped = False
if not password_auth.is_authed(request):
path, clamped = _clamp_to_root(path)
show_hidden = request.query.get("show_hidden") == "true"
sort = request.query.get("sort", "name")
res = fm_core.list_dir(path, show_hidden, sort)
if clamped:
res["clamped"] = True
return web.json_response(res)
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.get("/champdev/fm/file")
async def fm_file(request):
try:
path = request.query.get("path")
if not password_auth.is_authed(request) and not _inside_root(path):
return _browse_denied()
real = fm_core.safe_realpath(path)
if not os.path.isfile(real):
return web.json_response({"error": "not a file"}, status=404)
headers = {}
if request.query.get("download") == "1":
safe_name = (
os.path.basename(real)
.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "")
.replace("\n", "")
)
headers["Content-Disposition"] = f'attachment; filename="{safe_name}"'
return web.FileResponse(real, headers=headers)
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.get("/champdev/fm/thumbnail")
async def fm_thumbnail(request):
try:
if not password_auth.is_authed(request) and not _inside_root(request.query.get("path")):
return _browse_denied()
size = int(request.query.get("size", 256))
out = fm_core.make_thumbnail(request.query.get("path"), _thumb_cache_dir(), size)
return web.FileResponse(out)
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.get("/champdev/fm/properties")
async def fm_properties(request):
try:
if not password_auth.is_authed(request) and not _inside_root(request.query.get("path")):
return _browse_denied()
return web.json_response(fm_core.get_properties(request.query.get("path")))
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/champdev/fm/delete")
async def fm_delete(request):
if not password_auth.is_authed(request):
return web.json_response({"error": "unauthorized"}, status=401)
try:
data = await request.json()
return web.json_response({"results": fm_core.delete_paths(data.get("paths", []))})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/champdev/fm/rename")
async def fm_rename(request):
if not password_auth.is_authed(request):
return web.json_response({"error": "unauthorized"}, status=401)
try:
data = await request.json()
return web.json_response(fm_core.rename_path(data["path"], data["new_name"]))
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/champdev/fm/mkdir")
async def fm_mkdir(request):
if not password_auth.is_authed(request):
return web.json_response({"error": "unauthorized"}, status=401)
try:
data = await request.json()
return web.json_response(fm_core.make_dir(data["path"], data["name"]))
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/champdev/fm/move")
async def fm_move(request):
if not password_auth.is_authed(request):
return web.json_response({"error": "unauthorized"}, status=401)
try:
data = await request.json()
results = fm_core.move_paths(
data.get("paths", []), data["dest"], data.get("copy", False)
)
return web.json_response({"results": results})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@routes.post("/champdev/fm/upload")
async def fm_upload(request):
if not password_auth.is_authed(request):
return web.json_response({"error": "unauthorized"}, status=401)
try:
reader = await request.multipart()
dest = folder_paths.get_output_directory()
results = []
async for part in reader:
if part.name == "dest":
dest = (await part.text()).strip() or dest
elif part.name == "files":
data = await part.read()
results.append(fm_core.save_upload_bytes(dest, part.filename, data))
return web.json_response({"results": results})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
class ChampdevFM:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"start_path": ("STRING", {"default": folder_paths.get_output_directory()}),
"show_hidden": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = ()
FUNCTION = "noop"
CATEGORY = "chamdev-nodes/utils"
def noop(self, start_path="", show_hidden=False):
return ()
NODE_CLASS_MAPPINGS = {"ChampdevFM": ChampdevFM}
NODE_DISPLAY_NAME_MAPPINGS = {"ChampdevFM": "Champdev File Manager"}