From e3becf8a7fcb42a4534b9fa387c2c61a95eb3f4f Mon Sep 17 00:00:00 2001 From: Doudou Date: Sat, 26 Sep 2026 02:01:50 +0000 Subject: [PATCH] Workspace: private folder per account, owner-managed shared folders, Save asks where, Files tab --- __init__.py | 11 ++ rigshare_core/hub.py | 55 +++++++- rigshare_core/routes.py | 202 ++++++++++++++++++++++++++-- rigshare_core/workspace.py | 253 ++++++++++++++++++++++++++++++++++++ web/rigshare/main.js | 2 + web/rigshare/panel.js | 173 ++++++++++++++++++++---- web/rigshare/save-dialog.js | 157 ++++++++++++++++++++++ web/rigshare/styles.js | 18 +++ web/rigshare/sync.js | 23 ++++ 9 files changed, 854 insertions(+), 40 deletions(-) create mode 100644 rigshare_core/workspace.py create mode 100644 web/rigshare/save-dialog.js diff --git a/__init__.py b/__init__.py index cb681d7..582fce9 100644 --- a/__init__.py +++ b/__init__.py @@ -6,6 +6,7 @@ from server import PromptServer from .rigshare_core.store import Store from .rigshare_core.hub import Hub +from .rigshare_core.workspace import Workspace from .rigshare_core import routes NODE_CLASS_MAPPINGS = {} @@ -17,12 +18,22 @@ def data_folder(): return os.environ.get("RIGSHARE_DATA_DIR") or os.path.join(folder_paths.base_path, "rigshare") +def workflows_dir(): + """ComfyUI's workflows folder for the (single) default user.""" + try: + user_root = folder_paths.get_public_user_directory("default") + except Exception: + user_root = None + return os.path.join(user_root or os.path.join(folder_paths.get_user_directory(), "default"), "workflows") + + if hasattr(PromptServer, "instance"): try: folder = data_folder() os.makedirs(folder, exist_ok=True) store = Store(folder) hub = Hub(store, PromptServer.instance) + hub.workspace = Workspace(store, workflows_dir()) routes.setup(PromptServer.instance, store, hub) logging.info(f"[RigShare] ready, data in {folder}") except Exception as e: diff --git a/rigshare_core/hub.py b/rigshare_core/hub.py index 4180f60..b56b43c 100644 --- a/rigshare_core/hub.py +++ b/rigshare_core/hub.py @@ -103,6 +103,7 @@ class Hub: self.chat_dirty = False self.stats = None self._task = None + self.workspace = None # set by __init__ (folders, private spaces) # ----- lifecycle ---------------------------------------------------- @@ -247,9 +248,20 @@ class Hub: # ----- access lists ------------------------------------------------- + def access(self, room, kind, key, perms): + """What someone may do in a room: None (no access), "view" or "edit". + + A saved workflow's room also follows its folder (private spaces, + restricted shared folders): the stricter of the two wins. + """ + role = self._room_access(room, kind, key, perms) + if role and self.workspace and room.key.startswith("file:workflows/"): + from .workspace import min_role + role = min_role(role, self.workspace.role(room.key[len("file:workflows/"):], kind, key, perms)) + return role + @staticmethod - def access(room, kind, key, perms): - """What someone may do in a room: None (no access), "view" or "edit".""" + def _room_access(room, kind, key, perms): can_edit = bool(perms.get("edit") or perms.get("admin")) if perms.get("admin") or not room.acl: return "edit" if can_edit else "view" @@ -320,6 +332,37 @@ class Hub: await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)}) await self.broadcast_presence() + def rekey_rooms(self, old_prefix, new_prefix): + """Follow files that moved: rooms of ``file:...`` get the new path.""" + moved = [] + for key in list(self.rooms): + if key == old_prefix or key.startswith(old_prefix + "/"): + room = self.rooms.pop(key) + self.store.delete_room(key) + room.key = new_prefix + key[len(old_prefix):] + room.name = room.key.rsplit("/", 1)[-1].removesuffix(".json") + room.dirty = True + self.rooms[room.key] = room + for client in self.members(key): + client.room = None + moved.append(room.key) + return moved + + async def refresh_rooms_access(self): + """Folder access changed: remove people from rooms they can no longer open.""" + for client in list(self.clients.values()): + room = self.rooms.get(client.room) if client.room else None + if not room: + continue + role = self.client_access(room, client) + if role is None: + client.room = None + await self.broadcast({"type": "leave", "id": client.id}, room=room.key) + await self.send(client, {"type": "room_denied", "room": room.key, "name": room.name}) + else: + await self.send(client, {"type": "room_info", "room": room.key, **self.room_info(room, client)}) + await self.broadcast_presence() + def rename_member(self, old, new): for room in self.rooms.values(): changed = False @@ -358,7 +401,8 @@ class Hub: ws = web.WebSocketResponse(heartbeat=25, max_msg_size=MAX_MESSAGE) await ws.prepare(request) client = Client(ws) - client.cookie_token = request.cookies.get("rigshare_session") + auth = request.headers.get("Authorization", "") + client.cookie_token = request.cookies.get("rigshare_session") or (auth[7:].strip() if auth.startswith("Bearer ") else None) self.clients[client.id] = client try: async for msg in ws: @@ -395,6 +439,11 @@ class Hub: await self.send(client, {"type": "denied", "reason": "Please log in.", "login": True}) return first = not any(c.key == client.key and c is not client for c in self.clients.values()) + if self.workspace and client.kind == "user": + try: + self.workspace.ensure_private(client.key) + except OSError: + pass await self.send(client, { "type": "welcome", "self": client.public(), diff --git a/rigshare_core/routes.py b/rigshare_core/routes.py index dfb1585..ec6a9d9 100644 --- a/rigshare_core/routes.py +++ b/rigshare_core/routes.py @@ -12,6 +12,8 @@ from collections import defaultdict from aiohttp import web +from .workspace import Workspace, norm + log = logging.getLogger("ComfyUI-RigShare") # (method, path) prefixes that need a permission when api_protection is on. @@ -22,9 +24,6 @@ PROTECTED = [ ("POST", "/free", "queue"), ("POST", "/upload/", "edit|queue"), ("POST", "/api/jobs", "queue"), - # Saved workflow files: only admins delete, editors save/overwrite/rename. - ("DELETE", "/userdata/workflows/", "admin"), - ("POST", "/userdata/workflows/", "edit"), ] # ComfyUI Manager: everything needs the "manager" permission except a few @@ -293,6 +292,9 @@ def setup(server, store, hub): return error(str(e)) if user["username"] != username: hub.rename_member(username, user["username"]) + if hub.workspace: + hub.workspace.rename_user(username, user["username"]) + hub.rekey_rooms(f"file:workflows/users/{username}", f"file:workflows/users/{user['username']}") log.info(f"[RigShare] {admin['username']} renamed @{username} to @{user['username']}") await hub.refresh_identities() else: @@ -306,6 +308,8 @@ def setup(server, store, hub): try: store.delete_user(username) hub.forget_member(username) + if hub.workspace: + hub.workspace.forget_member(username) except KeyError: return error("No such user", 404) except ValueError as e: @@ -345,6 +349,83 @@ def setup(server, store, hub): async def list_rooms(request): return web.json_response([r for r in hub.room_list() if room_access(request, hub.rooms[r["key"]])]) + # ----- workspace folders ------------------------------------------ + + def ws(): + if not hub.workspace: + raise web.HTTPServiceUnavailable(text='{"error": "Workspace unavailable"}', content_type="application/json") + return hub.workspace + + @routes.get("/rigshare/api/workspace") + async def workspace_tree(request): + if not current_user(request): + return error("Not logged in", 401) + return web.json_response(ws().tree(*who(request))) + + @routes.get("/rigshare/api/workspace/folder") + async def folder_acl(request): + folder = norm(request.query.get("path")) + if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1: + return error("Not a shared folder") + if not ws().can_manage(folder, *who(request)): + return error("Only the folder's owner or an admin can see its access list", 403) + meta = ws().folders.get(folder) or {} + return web.json_response({"path": folder, "owner": meta.get("owner"), "restricted": bool(meta.get("acl")), + "members": (meta.get("acl") or {}).get("members") or {}}) + + @routes.post("/rigshare/api/workspace/folders") + async def create_folder(request): + kind, key, perms = who(request) + if kind != "user" or not (perms.get("edit") or perms.get("admin")): + return error("You need the Edit permission to create shared folders", 403) + body = await request.json() + try: + folder = ws().create_folder(body.get("name"), key) + except ValueError as e: + return error(str(e)) + log.info(f"[RigShare] {key} created folder {folder}") + return web.json_response({"path": folder}) + + @routes.patch("/rigshare/api/workspace/folders") + async def update_folder(request): + body = await request.json() + folder = norm(body.get("path")) + if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1: + return error("Not a shared folder") + if not ws().can_manage(folder, *who(request)): + return error("Only the folder's owner or an admin can change it", 403) + try: + if "restricted" in body: + acl = None + if body.get("restricted"): + owner = (ws().folders.get(folder) or {}).get("owner") + members = {u: r for u, r in (body.get("members") or {}).items() + if u in store.users and r in ("view", "edit") and u != owner} + acl = {"members": members} + kind, key, _ = who(request) + ws().set_acl(folder, acl, fallback_owner=key if kind == "user" else None) + if body.get("name"): + new_folder = ws().rename_folder(folder, body["name"]) + hub.rekey_rooms(f"file:workflows/{folder}", f"file:workflows/{new_folder}") + folder = new_folder + except ValueError as e: + return error(str(e)) + await hub.refresh_rooms_access() + return web.json_response({"ok": True, "path": folder}) + + @routes.delete("/rigshare/api/workspace/folders") + async def delete_folder(request): + folder = norm(request.query.get("path")) + if Workspace.area(folder)[0] != "shared" or folder.count("/") != 1: + return error("Not a shared folder") + if not ws().can_manage(folder, *who(request)): + return error("Only the folder's owner or an admin can delete it", 403) + try: + ws().delete_folder(folder) + except ValueError as e: + return error(str(e)) + return web.json_response({"ok": True}) + @routes.get("/rigshare/api/people") async def people(request): """Accounts that can be added to a workflow's access list.""" @@ -481,6 +562,101 @@ def setup(server, store, hub): continue return False + def parse_userdata(request): + """('list'|'get'|'save'|'delete'|'move', path, dest) for ComfyUI userdata calls under workflows/.""" + raw = request.raw_path.split("?", 1)[0] + segs = raw.split("/") + if "userdata" not in segs: + return None + i = segs.index("userdata") + if segs[i - 1] not in ("", "api", "v2"): + return None + if len(segs) == i + 1: # listing: /userdata?dir= or /v2/userdata?path= + if request.method != "GET": + return None + directory = norm(request.query.get("dir") or request.query.get("path") or "") + return ("list", directory, None) if Workspace.workflows_rel(directory) is not None else None + # ComfyUI encodes "/" inside names (one segment each), but accept plain + # slashes too so no spelling of a path can slip past the checks. + rest = segs[i + 1:] + if "move" in rest[1:]: + m = rest.index("move", 1) + src, dest = norm("/".join(rest[:m])), norm("/".join(rest[m + 1:])) + else: + src, dest = norm("/".join(rest)), None + if Workspace.workflows_rel(src) is None and (dest is None or Workspace.workflows_rel(dest) is None): + return None + if dest is not None: + return ("move", src, dest) + action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method) + return (action, src, None) if action else None + + def check_userdata(request, action, src, dest): + """Folder rules, then the per-workflow access list of a live room.""" + kind, key, perms = who(request) + w = hub.workspace + can_edit = bool(perms.get("edit") or perms.get("admin")) + + def role(path): + rel = Workspace.workflows_rel(path) + if rel is None: + return "edit" + folder = w.role(rel, kind, key, perms) if w else ("edit" if can_edit else "view") + room = hub.rooms.get("file:" + path) + if folder and room and room.acl: + from .workspace import min_role + folder = min_role(folder, hub.access(room, kind, key, perms)) + return folder + + def deletable(path): + rel = Workspace.workflows_rel(path) + if rel is None: + return True + if w: + return w.can_delete(rel, kind, key, perms) and role(path) is not None + return bool(perms.get("admin")) + + if action == "list": + return None + if action == "get": + return None if role(src) else "you do not have access to this workflow" + if action == "save": + return None if role(src) == "edit" else "you cannot save in this folder" + if action == "delete": + return None if deletable(src) else "only the folder's owner or an admin can delete this" + if action == "move": + if Workspace.workflows_rel(src) is None or Workspace.workflows_rel(dest) is None: + return "workflows cannot be moved in or out of the workflows folder" + if role(src) is None or role(dest) != "edit": + return "you cannot move this workflow there" + if w: + ok = w.can_move(Workspace.workflows_rel(src), Workspace.workflows_rel(dest), kind, key, perms) + else: + ok = role(src) == "edit" and (perms.get("admin") or src.rsplit("/", 1)[0] == dest.rsplit("/", 1)[0]) + return None if ok else "you cannot move this workflow there" + return None + + def filter_listing(request, response, directory): + try: + items = json.loads(response.body) + except Exception: + return response + if not isinstance(items, list): + return response + w = hub.workspace + kind, key, perms = who(request) + base = Workspace.workflows_rel(directory) or "" + + def rel_of(item): + p = item.get("path") if isinstance(item, dict) else (item[0] if isinstance(item, list) else item) + p = norm(p) + if p.startswith("workflows/"): + return p[len("workflows/"):] + return f"{base}/{p}" if base else p + + kept = [it for it in items if w.visible(rel_of(it), kind, key, perms)] + return web.json_response(kept, headers={"Cache-Control": "no-store"}) + @web.middleware async def protect(request, handler): path = request.path @@ -492,15 +668,11 @@ def setup(server, store, hub): raise web.HTTPFound(f"/rigshare/login?next={path}") return web.json_response({"error": "RigShare: login required. API clients: send " "'Authorization: Bearer '."}, status=401) - if path.startswith(("/userdata/workflows/", "/api/userdata/workflows/")) and not trusted(request.remote or ""): - # A restricted shared workflow's file follows its access list too. - rel = path.split("/userdata/", 1)[1].split("/move/", 1)[0] - room = hub.rooms.get("file:" + rel) - if room and room.acl: - role = room_access(request, room) - need = "view" if request.method in ("GET", "HEAD") else "edit" - if role is None or (need == "edit" and role != "edit"): - return web.json_response({"error": "RigShare: this workflow is restricted"}, status=403) + userdata = parse_userdata(request) + if userdata and not trusted(request.remote or ""): + refusal = check_userdata(request, *userdata) + if refusal: + return web.json_response({"error": f"RigShare: {refusal}"}, status=403) cfg = store.config.get("api_protection", {}) if cfg.get("enabled"): path = request.path[4:] if request.path.startswith("/api/") else request.path @@ -521,6 +693,12 @@ def setup(server, store, hub): status=403) break response = await handler(request) + # Workflow listings only show what this person may open. + if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace: + response = filter_listing(request, response, userdata[1]) + # A file moved on disk: its live room follows it. + if userdata and userdata[0] == "move" and getattr(response, "status", 0) == 200: + hub.rekey_rooms("file:" + userdata[1], "file:" + userdata[2]) # Hide API templates: rewrite the template index ComfyUI serves. if (store.config.get("hide_api_templates") and request.method == "GET" and path.startswith("/templates/index") and path.endswith(".json")): diff --git a/rigshare_core/workspace.py b/rigshare_core/workspace.py new file mode 100644 index 0000000..3535dd4 --- /dev/null +++ b/rigshare_core/workspace.py @@ -0,0 +1,253 @@ +"""Workspace folders: a private space per account plus shared folders. + +Inside ComfyUI's workflows directory:: + + users//... private: only its owner (and admins) + shared//... shared folders: owner + optional access list + anything else common: everyone, with their account permissions + +Paths handled here are relative to the *userdata* root, e.g. +``workflows/users/bob/flow.json``. ComfyUI decodes request paths twice, so +every path is fully URL-decoded and normalised before it is checked. +""" + +import os +import posixpath +import re +import shutil +import time +from urllib.parse import unquote + +WORKFLOWS = "workflows" +FOLDER_NAME_RE = re.compile(r"^[\w][\w .()-]{0,63}$") +ROLE_RANK = {None: 0, "view": 1, "edit": 2} + + +def full_unquote(value): + for _ in range(5): + if "%" not in value: + break + decoded = unquote(value) + if decoded == value: + break + value = decoded + return value + + +def norm(path): + """Decode and normalise a userdata-relative path ('' for the root).""" + path = full_unquote(str(path or "")).replace("\\", "/") + return posixpath.normpath("/" + path).lstrip("/") if path else "" + + +def min_role(a, b): + return a if ROLE_RANK[a] <= ROLE_RANK[b] else b + + +class Workspace: + def __init__(self, store, root): + self.store = store + self.root = root # absolute path of the workflows directory + self.folders = store._read("folders.json", {}) # "shared/" -> {owner, acl, created} + os.makedirs(os.path.join(root, "users"), exist_ok=True) + os.makedirs(os.path.join(root, "shared"), exist_ok=True) + + def save(self): + self.store._write("folders.json", self.folders) + + # ----- classification ------------------------------------------------- + + @staticmethod + def area(rel): + """Classify a path relative to the workflows dir. + + Returns ("private", username) | ("shared", "shared/") | + ("users-root", None) | ("shared-root", None) | ("common", None). + """ + parts = [p for p in rel.split("/") if p] + if not parts: + return "common", None + if parts[0] == "users": + return ("private", parts[1]) if len(parts) > 1 else ("users-root", None) + if parts[0] == "shared": + return ("shared", "shared/" + parts[1]) if len(parts) > 1 else ("shared-root", None) + return "common", None + + @staticmethod + def workflows_rel(userdata_path): + """'workflows/a/b.json' -> 'a/b.json'; None if outside workflows.""" + path = norm(userdata_path) + if path == WORKFLOWS: + return "" + if path.startswith(WORKFLOWS + "/"): + return path[len(WORKFLOWS) + 1:] + return None + + # ----- access ------------------------------------------------------- + + def folder_role(self, folder, kind, key, perms): + can_edit = bool(perms.get("edit") or perms.get("admin")) + meta = self.folders.get(folder) + if perms.get("admin") or meta is None or not meta.get("acl"): + return "edit" if can_edit else "view" + if kind == "user" and key == meta.get("owner"): + return "edit" if can_edit else "view" + role = (meta["acl"].get("members") or {}).get(key) if kind == "user" else None + if role not in ("view", "edit"): + return None + return "edit" if role == "edit" and can_edit else "view" + + def role(self, rel, kind, key, perms): + """What someone may do with a path under workflows/: None, "view" or "edit".""" + area, owner = self.area(rel) + if area == "private": + if perms.get("admin") or (kind == "user" and key == owner): + return "edit" # your own space, even with a viewer account + return None + if area == "shared": + return self.folder_role(owner, kind, key, perms) + if area in ("users-root", "shared-root"): + return "view" + return "edit" if (perms.get("edit") or perms.get("admin")) else "view" + + def can_manage(self, folder, kind, key, perms): + meta = self.folders.get(folder) or {} + return bool(perms.get("admin")) or (kind == "user" and key is not None and key == meta.get("owner")) + + def can_delete(self, rel, kind, key, perms): + """Deleting (or moving out of a folder) removes the file for others.""" + area, owner = self.area(rel) + if perms.get("admin"): + return True + if area == "private": + return kind == "user" and key == owner + if area == "shared": + return self.can_manage(owner, kind, key, perms) + return False # common files: admins only + + def can_move(self, src, dest, kind, key, perms): + if self.role(src, kind, key, perms) is None or self.role(dest, kind, key, perms) != "edit": + return False + if posixpath.dirname(src) == posixpath.dirname(dest): + return self.role(src, kind, key, perms) == "edit" # a rename in place + return self.can_delete(src, kind, key, perms) + + # ----- listings ------------------------------------------------------- + + def visible(self, rel, kind, key, perms): + return self.role(rel, kind, key, perms) is not None + + def _files(self, rel_dir): + base = os.path.join(self.root, *rel_dir.split("/")) if rel_dir else self.root + out = [] + if not os.path.isdir(base): + return out + for dirpath, dirnames, filenames in os.walk(base): + rel_here = os.path.relpath(dirpath, self.root).replace(os.sep, "/") + rel_here = "" if rel_here == "." else rel_here + if not rel_dir and rel_here.split("/")[0] in ("users", "shared"): + dirnames[:] = [] + continue + if not rel_dir: + dirnames[:] = [d for d in dirnames if d not in ("users", "shared")] + for name in filenames: + if not name.endswith(".json"): + continue + full = os.path.join(dirpath, name) + rel = f"{rel_here}/{name}" if rel_here else name + out.append({"path": f"{WORKFLOWS}/{rel}", "name": rel[len(rel_dir) + 1:] if rel_dir else rel, + "modified": int(os.path.getmtime(full))}) + out.sort(key=lambda f: f["name"].lower()) + return out + + def tree(self, kind, key, perms): + """Everything this person may see, for the Files tab.""" + can_edit = bool(perms.get("edit") or perms.get("admin")) + private = None + if kind == "user": + self.ensure_private(key) + private = {"path": f"users/{key}", "files": self._files(f"users/{key}")} + shared = [] + shared_dir = os.path.join(self.root, "shared") + names = sorted(os.listdir(shared_dir), key=str.lower) if os.path.isdir(shared_dir) else [] + for name in names: + folder = f"shared/{name}" + if not os.path.isdir(os.path.join(shared_dir, name)): + continue + role = self.folder_role(folder, kind, key, perms) + if role is None: + continue + meta = self.folders.get(folder) or {} + shared.append({"path": folder, "name": name, "owner": meta.get("owner"), + "restricted": bool(meta.get("acl")), "role": role, + "manage": self.can_manage(folder, kind, key, perms), + "files": self._files(folder)}) + return {"me": key if kind == "user" else None, "private": private, "shared": shared, + "common": {"files": self._files(""), "role": "edit" if can_edit else "view"}, + "can_create": can_edit} + + # ----- changes ------------------------------------------------------ + + def ensure_private(self, username): + os.makedirs(os.path.join(self.root, "users", username), exist_ok=True) + + def create_folder(self, name, owner): + name = (name or "").strip() + if not FOLDER_NAME_RE.match(name) or name in (".", ".."): + raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )") + path = os.path.join(self.root, "shared", name) + if os.path.exists(path): + raise ValueError("A folder with that name already exists") + os.makedirs(path) + self.folders[f"shared/{name}"] = {"owner": owner, "acl": None, "created": int(time.time())} + self.save() + return f"shared/{name}" + + def rename_folder(self, folder, new_name): + new_name = (new_name or "").strip() + if not FOLDER_NAME_RE.match(new_name): + raise ValueError("Folder names: up to 64 letters, digits, spaces and . _ - ( )") + new_folder = f"shared/{new_name}" + src = os.path.join(self.root, *folder.split("/")) + dst = os.path.join(self.root, "shared", new_name) + if os.path.exists(dst): + raise ValueError("A folder with that name already exists") + os.rename(src, dst) + self.folders[new_folder] = self.folders.pop(folder, {"owner": None, "acl": None}) + self.save() + return new_folder + + def delete_folder(self, folder): + path = os.path.join(self.root, *folder.split("/")) + if self._files(folder): + raise ValueError("Move or delete the workflows inside it first") + shutil.rmtree(path, ignore_errors=True) + self.folders.pop(folder, None) + self.save() + + def set_acl(self, folder, acl, fallback_owner=None): + meta = self.folders.setdefault(folder, {"owner": None, "acl": None, "created": int(time.time())}) + meta["acl"] = acl + if acl and not meta.get("owner"): + meta["owner"] = fallback_owner + self.save() + return meta + + def rename_user(self, old, new): + src = os.path.join(self.root, "users", old) + dst = os.path.join(self.root, "users", new) + if os.path.isdir(src) and not os.path.exists(dst): + os.rename(src, dst) + for meta in self.folders.values(): + if meta.get("owner") == old: + meta["owner"] = new + members = (meta.get("acl") or {}).get("members") or {} + if old in members: + members[new] = members.pop(old) + self.save() + + def forget_member(self, username): + for meta in self.folders.values(): + members = (meta.get("acl") or {}).get("members") or {} + members.pop(username, None) + self.save() diff --git a/web/rigshare/main.js b/web/rigshare/main.js index 3947201..f0fa45e 100644 --- a/web/rigshare/main.js +++ b/web/rigshare/main.js @@ -8,6 +8,7 @@ import { RoomSync } from "./sync.js"; import { Presence } from "./presence.js"; import { RigSharePanel } from "./panel.js"; import { STYLES } from "./styles.js"; +import { installSaveDialog } from "./save-dialog.js"; const TAB_ID = "rigshare"; const ICON_CLASS = "rigshare-tab-icon"; @@ -241,6 +242,7 @@ app.registerExtension({ installQueueGuard(client); installManagerGuard(client); installComfyAccountGuard(client); + installSaveDialog(app, api, client); presence.install(); app.extensionManager.registerSidebarTab({ diff --git a/web/rigshare/panel.js b/web/rigshare/panel.js index 2119cc4..8a8feb0 100644 --- a/web/rigshare/panel.js +++ b/web/rigshare/panel.js @@ -7,7 +7,8 @@ import { ServerWidget } from "./server-widget.js"; const ICON_URL = new URL("./assets/icon.svg", import.meta.url).href; const VIEWS = [ - { id: "workflows", icon: "pi-share-alt", label: "Workflows" }, + { id: "workflows", icon: "pi-share-alt", label: "Live" }, + { id: "files", icon: "pi-folder", label: "Files" }, { id: "people", icon: "pi-users", label: "People" }, { id: "chat", icon: "pi-comments", label: "Chat" }, { id: "server", icon: "pi-server", label: "Server" }, @@ -182,6 +183,7 @@ export class RigSharePanel { } const views = { workflows: () => this.workflowsView(), + files: () => this.filesView(), people: () => this.peopleView(), chat: () => [this.chatView], server: () => this.serverView(), @@ -269,49 +271,57 @@ export class RigSharePanel { } accessEditor() { - const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…")); const key = this.sync.targetKey; - Promise.all([ - this.run(() => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`)), - this.run(() => this.client.request("GET", "/rigshare/api/people")), - ]).then(([acl, people]) => { + return this.accessEditorFor({ + load: () => this.client.request("GET", `/rigshare/api/room/acl?key=${encodeURIComponent(key)}`), + save: (restricted, members) => this.sync.setAccess(restricted, members), + everyoneHint: "Anyone with an account can open it, with their usual permissions", + close: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); }, + draftKey: "accessDraft", + }); + } + + /** Access editor shared by live workflows and shared folders. */ + accessEditorFor({ load, save, everyoneHint, close, draftKey }) { + const box = h("div", { class: "rs-access-editor" }, h("p", { class: "rs-muted" }, "Loading…")); + Promise.all([this.run(load), this.run(() => this.client.request("GET", "/rigshare/api/people"))]).then(([acl, people]) => { if (!acl || !people) return; - const draft = this.accessDraft ??= { restricted: acl.restricted, members: { ...acl.members } }; + const draft = this[draftKey] ??= { restricted: acl.restricted, members: { ...acl.members } }; const render = () => { const fixed = people.filter((p) => p.username === acl.owner || p.admin); const choosable = people.filter((p) => p.username !== acl.owner && !p.admin); const fixedRows = fixed.map((p) => h("div", { class: "rs-row-item" }, h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`)), h("span", { class: "rs-chip" }, p.username === acl.owner ? "Owner · always" : "Admin · always"))); - const rows = choosable - .map((p) => { - const select = h("select", { - class: "rs-input rs-select", disabled: !draft.restricted, - onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; }, - }, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) => - h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label))); - return h("div", { class: "rs-row-item" }, - h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`), - p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")), - select); - }); + const rows = choosable.map((p) => { + const select = h("select", { + class: "rs-input rs-select", disabled: !draft.restricted, + onchange: (e) => { if (e.target.value) draft.members[p.username] = e.target.value; else delete draft.members[p.username]; }, + }, ...[["", "No access"], ["view", "Can view"], ["edit", "Can edit"]].map(([v, label]) => + h("option", { value: v, selected: (draft.members[p.username] || "") === v }, label))); + return h("div", { class: "rs-row-item" }, + h("div", { class: "rs-grow rs-min0 rs-ellipsis" }, p.display_name, h("span", { class: "rs-muted" }, ` @${p.username}`), + p.edit ? null : h("span", { class: "rs-muted rs-small" }, " · viewer account")), + select); + }); + const name = `rs-access-${draftKey}`; const radio = (value, label, hint) => h("label", { class: "rs-check rs-block" }, - h("input", { type: "radio", name: "rs-access", checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }), + h("input", { type: "radio", name, checked: draft.restricted === value, onchange: () => { draft.restricted = value; render(); } }), h("span", {}, label, h("div", { class: "rs-muted rs-small" }, hint))); box.replaceChildren(...[ - radio(false, "Everyone", "Anyone with an account can open it, with their usual permissions"), + radio(false, "Everyone", everyoneHint), radio(true, "Only people I choose", "The owner and admins always have access"), draft.restricted ? h("div", { class: "rs-access-list" }, ...fixedRows, ...rows, rows.length ? null : h("p", { class: "rs-muted rs-small" }, - "Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open this workflow.")) : null, + "Everyone else is an admin, and admins always have access. Add accounts without Admin under Admin → Accounts to choose who can open it.")) : null, h("div", { class: "rs-actions" }, h("button", { class: "rs-btn rs-btn-primary", onclick: async () => { - const ok = await this.run(() => this.sync.setAccess(draft.restricted, draft.restricted ? draft.members : {}), "Access updated"); - if (ok !== undefined) { this.accessOpen = false; this.accessDraft = null; this.renderBody(); } + const ok = await this.run(() => save(draft.restricted, draft.restricted ? draft.members : {}), "Access updated"); + if (ok !== undefined) { this[draftKey] = null; close(); } }, }, "Save"), - h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this.accessOpen = false; this.accessDraft = null; this.renderBody(); } }, "Cancel")), + h("button", { class: "rs-btn rs-btn-ghost", onclick: () => { this[draftKey] = null; close(); } }, "Cancel")), ].filter(Boolean)); }; render(); @@ -381,6 +391,119 @@ export class RigSharePanel { ...(rows.length ? rows : [h("p", { class: "rs-muted" }, "Nothing shared yet. Open a saved workflow or edit a tab.")])); } + // ----- files ---------------------------------------------------------- + + filesView() { + this.filesEl ??= h("div", {}); + this.loadFiles(); + return [this.filesEl]; + } + + async loadFiles() { + const tree = await this.run(() => this.client.request("GET", "/rigshare/api/workspace")); + if (!tree) return; + this.tree = tree; + const destinations = [ + tree.private ? { folder: tree.private.path, label: "My files" } : null, + ...tree.shared.filter((f) => f.role === "edit").map((f) => ({ folder: f.path, label: f.name })), + tree.common.role === "edit" ? { folder: "", label: "Common" } : null, + ].filter(Boolean); + + const fileRow = (file, folder, canMoveOut) => h("div", { class: "rs-row-item rs-file" }, + h("i", { class: "pi pi-file rs-row-icon" }), + h("div", { class: "rs-grow rs-min0" }, + h("div", { class: "rs-name rs-ellipsis", title: file.path.slice("workflows/".length) }, file.name.replace(/\.json$/, "")), + h("div", { class: "rs-muted rs-small" }, timeAgo(file.modified * 1000))), + canMoveOut && destinations.length > 1 ? h("select", { + class: "rs-input rs-select", title: "Move to another folder", + onchange: async (e) => { + const dest = e.target.value; + e.target.value = "__"; + if (dest === "__") return; + const base = file.path.split("/").pop(); + const target = dest ? `workflows/${dest}/${base}` : `workflows/${base}`; + await this.run(() => this.sync.movePath(file.path, target), "Moved"); + this.loadFiles(); + }, + }, h("option", { value: "__" }, "Move to…"), ...destinations.filter((d) => d.folder !== folder).map((d) => + h("option", { value: d.folder }, d.label))) : null, + h("button", { class: "rs-btn rs-btn-icon", title: "Open", onclick: () => this.run(() => this.sync.openPath(file.path)) }, + h("i", { class: "pi pi-arrow-right" }))); + + const empty = (text) => h("p", { class: "rs-muted rs-small" }, text); + const sections = []; + if (tree.private) { + sections.push(this.section("pi-user", "My files", + h("p", { class: "rs-muted rs-small" }, "Only you (and admins) can see these."), + ...(tree.private.files.length ? tree.private.files.map((f) => fileRow(f, tree.private.path, true)) : [empty("Nothing here yet. Save a workflow and choose My files.")]))); + } + + const folderBlocks = tree.shared.map((f) => { + const open = this.openFolders?.has(f.path); + const owner = f.owner ? this.client.users.find((u) => u.key === f.owner)?.name ?? `@${f.owner}` : null; + const head = h("div", { class: "rs-row-item rs-folder" }, + h("button", { class: "rs-btn rs-btn-icon rs-btn-ghost", title: open ? "Collapse" : "Expand", onclick: () => { + this.openFolders ??= new Set(); + open ? this.openFolders.delete(f.path) : this.openFolders.add(f.path); + this.loadFiles(); + } }, h("i", { class: `pi ${open ? "pi-chevron-down" : "pi-chevron-right"}` })), + h("i", { class: `pi ${f.restricted ? "pi-lock" : "pi-folder"} rs-row-icon` }), + h("div", { class: "rs-grow rs-min0" }, + h("div", { class: "rs-name rs-ellipsis" }, f.name), + h("div", { class: "rs-muted rs-small" }, `${f.files.length} workflow${f.files.length === 1 ? "" : "s"}${owner ? ` · owner ${owner}` : ""}${f.role === "view" ? " · view only" : ""}`)), + f.manage ? h("button", { class: `rs-btn rs-btn-sm ${this.folderAccess === f.path ? "active" : ""}`, onclick: () => { + this.folderAccess = this.folderAccess === f.path ? null : f.path; + this.folderDraft = null; + this.loadFiles(); + } }, "Access") : null, + f.manage ? h("button", { class: "rs-btn rs-btn-icon", title: "Rename folder", onclick: async () => { + const name = await this.prompt("Rename folder", `New name for "${f.name}"`, f.name); + if (!name || name === f.name) return; + await this.run(() => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, name }), "Folder renamed"); + this.loadFiles(); + } }, h("i", { class: "pi pi-pencil" })) : null, + f.manage ? h("button", { class: "rs-btn rs-btn-icon rs-danger", title: "Delete folder (must be empty)", onclick: async () => { + if (!(await this.confirm("Delete folder", `Delete the empty folder "${f.name}"?`))) return; + await this.run(() => this.client.request("DELETE", `/rigshare/api/workspace/folders?path=${encodeURIComponent(f.path)}`), "Folder deleted"); + this.loadFiles(); + } }, h("i", { class: "pi pi-trash" })) : null); + const parts = [head]; + if (this.folderAccess === f.path) { + parts.push(this.accessEditorFor({ + load: async () => { + const t = await this.client.request("GET", "/rigshare/api/workspace"); + const folder = t.shared.find((x) => x.path === f.path); + const members = await this.client.request("GET", `/rigshare/api/workspace/folder?path=${encodeURIComponent(f.path)}`); + return { owner: folder?.owner, restricted: !!folder?.restricted, members: members.members || {} }; + }, + save: (restricted, members) => this.client.request("PATCH", "/rigshare/api/workspace/folders", { path: f.path, restricted, members }), + everyoneHint: "Anyone with an account can open its workflows, with their usual permissions", + close: () => { this.folderAccess = null; this.loadFiles(); }, + draftKey: "folderDraft", + })); + } + if (open) { + parts.push(h("div", { class: "rs-folder-files" }, + ...(f.files.length ? f.files.map((file) => fileRow(file, f.path, f.manage)) : [empty("Empty folder.")]))); + } + return h("div", { class: "rs-folder-block" }, ...parts); + }); + const newFolderBtn = tree.can_create ? h("button", { class: "rs-btn", onclick: async () => { + const name = await this.prompt("New shared folder", "Folder name"); + if (!name) return; + await this.run(() => this.client.request("POST", "/rigshare/api/workspace/folders", { name }), "Folder created"); + this.loadFiles(); + } }, h("i", { class: "pi pi-folder-plus" }), "New shared folder") : null; + sections.push(this.section("pi-folder", "Shared folders", + ...(folderBlocks.length ? folderBlocks : [empty("No shared folders yet.")]), + newFolderBtn ? h("div", { class: "rs-actions" }, newFolderBtn) : null)); + + sections.push(this.section("pi-globe", "Common", + h("p", { class: "rs-muted rs-small" }, "Top-level workflows, visible to everyone."), + ...(tree.common.files.length ? tree.common.files.map((f) => fileRow(f, "", this.client.perms.admin)) : [empty("No workflows at the top level.")]))); + this.filesEl.replaceChildren(...sections); + } + // ----- people --------------------------------------------------------- peopleView() { diff --git a/web/rigshare/save-dialog.js b/web/rigshare/save-dialog.js new file mode 100644 index 0000000..1d7c96d --- /dev/null +++ b/web/rigshare/save-dialog.js @@ -0,0 +1,157 @@ +// "Save" asks where: My files, a shared folder, or Common. +// +// ComfyUI's save flow asks the workflow for a file name (promptSave) and saves +// to "/.json". Returning "/" makes it +// save straight into the chosen folder, with ComfyUI's own overwrite checks. + +import { h, storage } from "./ui.js"; + +const LAST_FOLDER = "rigshare.lastSaveFolder"; + +function cleanName(name) { + return (name || "").trim().replace(/\.json$/i, "").replace(/[\\/]+/g, "-").slice(0, 120); +} + +export function installSaveDialog(app, api, client) { + const patch = () => { + const wf = app.extensionManager?.workflow?.activeWorkflow; + if (!wf) return false; + let proto = Object.getPrototypeOf(wf); + while (proto && !Object.prototype.hasOwnProperty.call(proto, "promptSave")) proto = Object.getPrototypeOf(proto); + if (!proto) return false; + if (proto.__rigshare) return true; + const original = proto.promptSave; + proto.promptSave = async function (...args) { + if (!client.online || !client.self || !this.path?.startsWith("workflows/")) return original.apply(this, args); + try { + return await chooseLocation(app, api, client, this); + } catch (e) { + console.error("[RigShare] save dialog", e); + return original.apply(this, args); + } + }; + proto.__rigshare = true; + return true; + }; + const timer = setInterval(() => { if (patch()) clearInterval(timer); }, 1000); +} + +export async function chooseLocation(app, api, client, wf) { + let tree = await client.request("GET", "/rigshare/api/workspace"); + const directory = wf.directory || "workflows"; + + const options = () => { + const out = []; + if (tree.private) out.push({ folder: tree.private.path, label: "My files", hint: "Only you", icon: "pi-user" }); + for (const f of tree.shared) { + if (f.role === "edit") out.push({ folder: f.path, label: f.name, hint: f.restricted ? "Shared with chosen people" : "Shared with everyone", icon: f.restricted ? "pi-lock" : "pi-folder" }); + } + if (tree.common?.role === "edit") out.push({ folder: "", label: "Common", hint: "Top level, visible to everyone", icon: "pi-globe" }); + return out; + }; + + const current = directory === "workflows" ? null : directory.slice("workflows/".length); + const pick = (opts) => { + const remembered = storage.get(LAST_FOLDER); + return opts.find((o) => o.folder === current)?.folder + ?? opts.find((o) => o.folder === remembered)?.folder + ?? opts[0]?.folder; + }; + + const result = await new Promise((resolve) => { + let opts = options(); + let selected = pick(opts); + const name = h("input", { class: "rs-input", value: cleanName(wf.filename) || "Untitled", maxLength: 120 }); + const list = h("div", { class: "rs-save-list" }); + const newFolder = h("input", { class: "rs-input", placeholder: "New shared folder name", maxLength: 64 }); + const error = h("div", { class: "rs-error" }); + + const renderList = () => { + list.replaceChildren(...opts.map((o) => h("label", { class: `rs-save-option ${o.folder === selected ? "on" : ""}` }, + h("input", { type: "radio", name: "rs-save-folder", checked: o.folder === selected, onchange: () => { selected = o.folder; renderList(); } }), + h("i", { class: `pi ${o.icon}` }), + h("span", { class: "rs-grow rs-min0" }, + h("div", { class: "rs-name rs-ellipsis" }, o.label), + h("div", { class: "rs-muted rs-small" }, o.hint))))); + }; + + const close = (value) => { + backdrop.remove(); + document.removeEventListener("keydown", onKey, true); + resolve(value); + }; + const submit = () => { + const n = cleanName(name.value); + if (!n) { error.textContent = "Enter a name"; return; } + if (selected === undefined) { error.textContent = "Choose where to save it"; return; } + storage.set(LAST_FOLDER, selected); + close({ name: n, folder: selected }); + }; + const onKey = (e) => { + if (!backdrop.isConnected) return; + if (e.key === "Escape") { e.stopPropagation(); close(null); } + if (e.key === "Enter" && document.activeElement !== newFolder) { e.preventDefault(); e.stopPropagation(); submit(); } + }; + + const canCreate = tree.can_create; + const createRow = canCreate ? h("div", { class: "rs-row" }, newFolder, + h("button", { + class: "rs-btn", onclick: async () => { + error.textContent = ""; + try { + const res = await client.request("POST", "/rigshare/api/workspace/folders", { name: newFolder.value }); + tree = await client.request("GET", "/rigshare/api/workspace"); + opts = options(); + selected = res.path; + newFolder.value = ""; + renderList(); + } catch (e) { error.textContent = e.message; } + }, + }, h("i", { class: "pi pi-folder-plus" }), "Create")) : null; + + const backdrop = h("div", { class: "rs-modal-backdrop rs-panel-vars", onmousedown: (e) => { if (e.target === backdrop) close(null); } }, + h("div", { class: "rs-modal", role: "dialog", "aria-label": "Save workflow" }, + h("div", { class: "rs-modal-title" }, h("i", { class: "pi pi-save" }), "Save workflow"), + h("label", { class: "rs-label" }, "Name"), name, + h("label", { class: "rs-label" }, "Save in"), list, + createRow, error, + h("div", { class: "rs-actions rs-modal-actions" }, + h("button", { class: "rs-btn rs-btn-ghost", onclick: () => close(null) }, "Cancel"), + h("button", { class: "rs-btn rs-btn-primary", onclick: submit }, "Save")))); + backdrop.addEventListener("keydown", (e) => e.stopPropagation()); + renderList(); + document.body.append(backdrop); + document.addEventListener("keydown", onKey, true); + setTimeout(() => { name.focus(); name.select(); }, 30); + }); + + if (!result) return null; + const targetDir = result.folder ? `workflows/${result.folder}` : "workflows"; + if (targetDir === directory) return result.name; + if (directory === "workflows") return `${result.folder}/${result.name}`; + // "Save As" from a file that lives in another folder: write the copy + // ourselves, open it, and tell ComfyUI there is nothing left to do. + return await saveCopyElsewhere(app, api, client, wf, `${targetDir}/${result.name}.json`); +} + +async function saveCopyElsewhere(app, api, client, wf, path) { + const store = app.extensionManager.workflow; + await store.syncWorkflows?.(); + if (store.getWorkflowByPath?.(path)) { + const dialog = app.extensionManager?.dialog; + const ok = dialog?.confirm + ? await dialog.confirm({ title: "Overwrite?", message: `${path.slice("workflows/".length)} already exists. Replace it?` }) + : window.confirm(`${path} already exists. Replace it?`); + if (!ok) return null; + } + const state = wf.activeState ?? app.rootGraph?.serialize?.() ?? app.graph.serialize(); + await api.storeUserData(path, JSON.stringify(state), { overwrite: true, full_info: true, throwOnError: true }); + await store.syncWorkflows?.(); + const copy = store.getWorkflowByPath?.(path); + if (copy) { + if (!copy.isLoaded) await copy.load(); + await app.loadGraphData(copy.activeState, true, true, copy); + } + client.emit("toast", { severity: "success", summary: "RigShare", detail: `Saved as ${path.slice("workflows/".length)}` }); + return null; +} diff --git a/web/rigshare/styles.js b/web/rigshare/styles.js index 3ddda20..39d4896 100644 --- a/web/rigshare/styles.js +++ b/web/rigshare/styles.js @@ -170,6 +170,24 @@ body.rs-no-manager [aria-label="ComfyUI Manager"] { display: none !important; } .rs-gpu-name .rs-muted { font-weight: 400; font-size: 11px; } .rs-queue-line { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 6px 8px; border-radius: 6px; background: var(--rs-bg2); } +/* files */ +.rs-folder-block + .rs-folder-block { border-top: 1px solid var(--rs-soft); } +.rs-folder-files { margin-left: 26px; } +.rs-file .rs-select { max-width: 110px; } + +/* save dialog */ +.rs-modal-backdrop { position: fixed; inset: 0; z-index: 3000; background: rgb(0 0 0 / .55); display: grid; place-items: center; padding: 16px; color: var(--rs-fg); font-size: 13px; } +.rs-modal { width: 100%; max-width: 440px; max-height: 90vh; overflow-y: auto; background: var(--rs-bg); border: 1px solid var(--rs-border); + border-radius: 12px; padding: 18px 18px 14px; box-shadow: 0 20px 60px rgb(0 0 0 / .45); display: flex; flex-direction: column; gap: 6px; } +.rs-modal-title { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 650; margin-bottom: 6px; } +.rs-modal-actions { justify-content: flex-end; margin-top: 10px; } +.rs-save-list { display: flex; flex-direction: column; gap: 4px; max-height: 260px; overflow-y: auto; margin-bottom: 6px; } +.rs-save-option { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border: 1px solid var(--rs-border); border-radius: 8px; cursor: pointer; } +.rs-save-option.on { border-color: var(--rs-accent); background: color-mix(in srgb, var(--rs-accent) 12%, transparent); } +.rs-save-option input { margin: 0; } +.rs-error { color: #f87171; font-size: 12px; } +.rs-error:empty { display: none; } + /* floating server monitor */ .rs-float { position: fixed; z-index: 1000; width: 240px; background: var(--rs-bg); border: 1px solid var(--rs-border); border-radius: 10px; box-shadow: 0 10px 30px rgb(0 0 0 / .35); font-size: 11px; user-select: none; } diff --git a/web/rigshare/sync.js b/web/rigshare/sync.js index 4ea5364..6960959 100644 --- a/web/rigshare/sync.js +++ b/web/rigshare/sync.js @@ -541,6 +541,29 @@ export class RoomSync extends EventTarget { }; } + /** Open a saved workflow file (like ComfyUI's workflow browser does). */ + async openPath(path) { + const store = this.store; + await store.syncWorkflows?.(); + const wf = store.getWorkflowByPath?.(path); + if (!wf) throw new Error("That workflow no longer exists"); + if (wf === store.activeWorkflow) return; + if (!wf.isLoaded) await wf.load(); + await this.app.loadGraphData(wf.activeState, true, true, wf); + } + + /** Move a saved workflow to another folder (ComfyUI's own rename). */ + async movePath(path, target) { + const store = this.store; + await store.syncWorkflows?.(); + const wf = store.getWorkflowByPath?.(path); + if (!wf) throw new Error("That workflow no longer exists"); + if (store.getWorkflowByPath?.(target)) throw new Error("A workflow with that name already exists there"); + if (store.renameWorkflow) await store.renameWorkflow(wf, target); + else await wf.rename(target); + await store.syncWorkflows?.(); + } + /** Find an already-open tab for a room. */ findTab(room) { for (const wf of this.store?.openWorkflows || []) {