"""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 json import os import posixpath import re import shutil import time from urllib.parse import unquote WORKFLOWS = "workflows" FOLDER_NAME_RE = re.compile(r"^(?!(?i:con|prn|aux|nul|com\d|lpt\d)(?:\.|$))\w(?:[\w .()-]{0,62}[\w()-])?\Z") # no trailing dot/space, no Windows device names 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 _WIN = os.name == "nt" _SHORT_NAME_RE = re.compile(r"~\d") # Windows 8.3 alias of a long name (USERS~1) _case_root = None # workflows directory used to restore on-disk casing (Windows only) def _on_disk_case(parts, base): """Windows ignores case: map each segment to the casing it has on disk, so 'Users/Bob' is judged as the same path it will open.""" out = [] for part in parts: try: names = os.listdir(base) if base else [] except OSError: names = [] match = part if part in names else next((n for n in names if n.casefold() == part.casefold()), part) out.append(match) base = os.path.join(base, match) if base and match in names else None return out class InvalidPath(PermissionError): """A path Windows would read as a different one than the checks do: refused, never adjusted.""" def norm(path): """Decode and normalise a userdata-relative path ('' for the root). A path with a segment Windows would read as something else is refused (InvalidPath), not adjusted: a colon (drive prefix 'C:', NTFS stream), a 8.3 short name, or trailing dots or spaces (Windows strips them, so 'users./bob' would open 'users/bob'). Casing is restored from disk on Windows. Anything that still reaches the disk goes through safe_join(). """ path = full_unquote(str(path or "")).replace("\\", "/") if not path: return "" clean = posixpath.normpath("/" + path).lstrip("/") parts = [p for p in clean.split("/") if p] for p in parts: if ":" in p or (_WIN and (_SHORT_NAME_RE.search(p) or p != p.rstrip(" ."))): raise InvalidPath("Invalid path") if _WIN and _case_root and parts: lead = [WORKFLOWS] if parts[0].casefold() == WORKFLOWS else [] parts = lead + _on_disk_case(parts[len(lead):], _case_root) return "/".join(parts) def safe_join(root, *parts): """Join relative parts under root; refuse anything that resolves outside it.""" segments = [p for part in parts for p in str(part).replace("\\", "/").split("/") if p] if any(":" in p or p in (".", "..") or (_WIN and p != p.rstrip(" .")) for p in segments): raise PermissionError("Invalid path") full = os.path.join(root, *segments) base = os.path.realpath(root) try: inside = os.path.commonpath([base, os.path.realpath(full)]) == base except ValueError: # different drives inside = False if not inside: raise PermissionError("Invalid path") return full def has_app(doc): """A workflow with an App set up (ComfyUI App mode: chosen inputs or outputs).""" data = (doc or {}).get("extra") if isinstance(doc, dict) else None linear = (data or {}).get("linearData") if isinstance(data, dict) else None return isinstance(linear, dict) and bool(linear.get("inputs") or linear.get("outputs")) 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 global _case_root _case_root = root 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) self._app_cache = {} # absolute path -> (mtime, size, has an App) 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 = safe_join(self.root, rel_dir) 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 safe_join(self.root, rel) def _file_has_app(self, full): """Read a workflow file only when it changed since the last check.""" try: st = os.stat(full) except OSError: return False cached = self._app_cache.get(full) if cached and cached[0] == st.st_mtime and cached[1] == st.st_size: return cached[2] try: with open(full, "r", encoding="utf-8") as f: text = f.read() found = '"linearData"' in text and has_app(json.loads(text)) except (OSError, ValueError): found = False self._app_cache[full] = (st.st_mtime, st.st_size, found) return found 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 or (not is_dir and self._file_has_app(full)): 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())) out = {"path": rel, "role": role, "entries": entries, "can_mkdir": can_mkdir} # Inside a shared folder: which one, and whether this person manages its access. area, folder = self.area(rel) if rel != "@shared" else (None, None) if area == "shared": meta = self.folders.get(folder) or {} out["shared_folder"] = {"path": folder, "name": folder.split("/", 1)[1], "owner": meta.get("owner"), "restricted": bool(meta.get("acl")), "manage": self.can_manage(folder, kind, key, perms)} return out 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 = safe_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 = safe_join(self.root, folder) dst = safe_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 = safe_join(self.root, folder) 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()