Files
xiaodoudou-ComfyUI-RigShare/rigshare_core/workspace.py
T
Edouard Durand 51c16fa93e App mode support; hide ComfyUI's Workflows and Apps tabs
Live edits keep each person's graph/App view, cursors are off in the App
view, and .app.json workflows show as apps in Files, the Live list and Save.
ComfyUI's own file tabs duplicate Files, so they are hidden by default (admin
setting hide_comfy_file_tabs) and their shortcuts open Files.
2026-09-26 03:43:46 +00:00

396 lines
18 KiB
Python

"""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}
# ----- browsing (Files tab) -------------------------------------------
def _abs(self, rel):
return os.path.join(self.root, *[p for p in rel.split("/") if p])
def _entry(self, rel, name, is_dir, kind, key, perms):
full = self._abs(rel)
is_app = not is_dir and name.endswith(".app.json") # saved as an App (ComfyUI App mode)
entry = {"type": "folder" if is_dir else "file", "path": rel,
"name": name if is_dir else name[:-9] if is_app else name[:-5],
"modified": int(os.path.getmtime(full)) if os.path.exists(full) else 0,
"role": self.role(rel, kind, key, perms)}
area, folder = self.area(rel)
if is_dir and area == "shared" and rel == folder:
meta = self.folders.get(folder) or {}
entry.update(owner=meta.get("owner"), restricted=bool(meta.get("acl")),
manage=self.can_manage(folder, kind, key, perms))
if is_app:
entry["app"] = True
entry["deletable"] = self.can_delete(rel, kind, key, perms)
return entry
def list_dir(self, rel, kind, key, perms):
"""One folder's content. "@shared" is the Shared root: the shared folders
plus the common top level. "users" (admins) lists everyone's folders.
Raises PermissionError / FileNotFoundError."""
can_edit = bool(perms.get("edit") or perms.get("admin"))
entries = []
if rel == "@shared":
shared_dir = self._abs("shared")
for name in (os.listdir(shared_dir) if os.path.isdir(shared_dir) else []):
path = "shared/" + name
if os.path.isdir(self._abs(path)) and self.folder_role(path, kind, key, perms):
entries.append(self._entry(path, name, True, kind, key, perms))
for name in os.listdir(self.root):
if name in ("users", "shared") or name.startswith("."):
continue
full = os.path.join(self.root, name)
if os.path.isdir(full) or name.endswith(".json"):
entries.append(self._entry(name, name, os.path.isdir(full), kind, key, perms))
role = "edit" if can_edit else "view"
can_mkdir = can_edit and kind == "user"
else:
rel = norm(rel)
if rel == "users" and not perms.get("admin"):
raise PermissionError("You do not have access to this folder")
role = self.role(rel, kind, key, perms)
if role is None:
raise PermissionError("You do not have access to this folder")
if kind == "user" and rel == "users/" + str(key):
self.ensure_private(key)
base = self._abs(rel)
if not os.path.isdir(base):
raise FileNotFoundError("This folder no longer exists")
for name in os.listdir(base):
if name.startswith("."):
continue
child = rel + "/" + name if rel else name
full = os.path.join(base, name)
if not (os.path.isdir(full) or name.endswith(".json")):
continue
if self.role(child, kind, key, perms) is None:
continue
entries.append(self._entry(child, name, os.path.isdir(full), kind, key, perms))
can_mkdir = role == "edit" and rel not in ("", "users", "shared")
entries.sort(key=lambda e: (e["type"] != "folder", e["name"].lower()))
return {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir}
def make_dir(self, parent, name, kind, key, perms):
"""New folder. In the Shared root it becomes a shared folder you own."""
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 . _ - ( )")
if parent == "@shared":
if kind != "user" or not (perms.get("edit") or perms.get("admin")):
raise PermissionError("You need the Edit permission to create shared folders")
return self.create_folder(name, key)
parent = norm(parent)
if parent in ("", "users", "shared") or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot create a folder here")
path = parent + "/" + name
if os.path.exists(self._abs(path)):
raise ValueError("A folder with that name already exists")
os.makedirs(self._abs(path))
return path
def _top_level(self, rel):
area, folder = self.area(rel)
return (area == "shared" and rel == folder) or (area == "private" and rel.count("/") < 2) \
or rel in ("users", "shared")
def rename_dir(self, rel, name, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "shared" and rel == folder:
if not self.can_manage(folder, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can rename it")
return self.rename_folder(folder, name)
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 . _ - ( )")
parent = posixpath.dirname(rel)
if self._top_level(rel) or not self.can_delete(rel, kind, key, perms) \
or self.role(parent, kind, key, perms) != "edit":
raise PermissionError("You cannot rename this folder")
new = parent + "/" + name if parent else name
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists")
os.rename(self._abs(rel), self._abs(new))
return new
def move_dir(self, rel, dest_parent, kind, key, perms):
rel, dest_parent = norm(rel), ("" if dest_parent == "@shared" else norm(dest_parent))
if self._top_level(rel):
raise PermissionError("Top-level folders cannot be moved")
if dest_parent == rel or dest_parent.startswith(rel + "/"):
raise ValueError("A folder cannot be moved into itself")
if dest_parent in ("users", "shared") or not self.can_delete(rel, kind, key, perms) \
or self.role(dest_parent, kind, key, perms) != "edit":
raise PermissionError("You cannot move this folder there")
new = dest_parent + "/" + posixpath.basename(rel) if dest_parent else posixpath.basename(rel)
if os.path.exists(self._abs(new)):
raise ValueError("A folder with that name already exists there")
os.rename(self._abs(rel), self._abs(new))
return new
def delete_dir(self, rel, kind, key, perms):
rel = norm(rel)
area, folder = self.area(rel)
if area == "private" and rel.count("/") < 2 or rel in ("", "users", "shared"):
raise PermissionError("This folder cannot be deleted")
if not self.can_delete(rel, kind, key, perms):
raise PermissionError("Only the folder's owner or an admin can delete it")
has_files = any(files for _, _, files in os.walk(self._abs(rel)))
own_private = area == "private" and kind == "user" and folder == key
if has_files and not own_private:
raise ValueError("Move or delete the workflows inside it first")
shutil.rmtree(self._abs(rel), ignore_errors=True)
if area == "shared" and rel == folder:
self.folders.pop(folder, None)
self.save()
# ----- 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()