Workspace: private folder per account, owner-managed shared folders, Save asks where, Files tab

This commit is contained in:
Doudou
2026-09-26 02:01:50 +00:00
parent 4b6c588806
commit e3becf8a7f
9 changed files with 854 additions and 40 deletions
+11
View File
@@ -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:
+52 -3
View File
@@ -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:<old_prefix>...`` 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(),
+190 -12
View File
@@ -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 <api key>'."}, 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")):
+253
View File
@@ -0,0 +1,253 @@
"""Workspace folders: a private space per account plus shared folders.
Inside ComfyUI's workflows directory::
users/<username>/... private: only its owner (and admins)
shared/<folder>/... 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/<name>" -> {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/<name>") |
("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()
+2
View File
@@ -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({
+148 -25
View File
@@ -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() {
+157
View File
@@ -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 "<workflow directory>/<name>.json". Returning "<folder>/<name>" 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;
}
+18
View File
@@ -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; }
+23
View File
@@ -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 || []) {