Files
xiaodoudou-ComfyUI-RigShare/rigshare_core/store.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

480 lines
19 KiB
Python

"""Persistent state for RigShare: accounts, sessions, guests, config, rooms
and snapshots.
Everything lives as small JSON files in ``<ComfyUI>/rigshare``. Writes are atomic
(write to a temp file then rename) so a crash never leaves a half-written file.
"""
import hashlib
import hmac
import json
import logging
import os
import re
import secrets
import threading
import time
from . import jwt
log = logging.getLogger("ComfyUI-RigShare")
PBKDF2_ITERATIONS = 240_000
SESSION_TTL = 30 * 24 * 3600 # a login lasts at most this long
TOKEN_TTL = 12 * 3600 # each JWT is short-lived...
TOKEN_REFRESH = 6 * 3600 # ...and re-issued while in use once older than this
ISSUER = "rigshare"
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{2,32}$")
PERMS = ("edit", "queue", "manager", "admin")
API_KEY_PREFIX = "rs_"
DEFAULT_CONFIG = {
"server_name": "RigShare",
# Everyone must log in before ComfyUI loads (login page + session cookie).
# API clients authenticate with a personal API key.
"require_login": True,
# Only when require_login is off: visitors without an account can watch and chat.
"allow_guests": False,
"guest_permissions": {"edit": False, "queue": False},
# Relay execution progress/previews to every connected browser, not only
# the one that queued the prompt.
"broadcast_execution": True,
# Reject queue/interrupt/upload HTTP calls from users lacking permission.
# Requests from trusted IPs skip both this and the login requirement.
"api_protection": {
"enabled": True,
"trusted_ips": ["127.0.0.1", "::1"],
},
# Hide ComfyUI's own Comfy.org account sign-in (top-bar button and dialog).
"hide_comfy_account": False,
# ComfyUI's own Workflows and Apps sidebar tabs duplicate RigShare's Files tab.
"hide_comfy_file_tabs": True,
# Remove templates that need paid API nodes from the template browser.
"hide_api_templates": False,
# Rooms nobody opened for this many days are forgotten.
"room_expiry_days": 14,
"snapshot_interval_sec": 300,
"snapshot_keep": 30,
"chat_keep": 0, # 0 keeps the whole chat; N trims it to the last N messages at startup
}
def _hash_password(password, salt=None):
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}"
def _verify_password(password, stored):
try:
_, iterations, salt, digest = stored.split("$")
candidate = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iterations))
return hmac.compare_digest(candidate.hex(), digest)
except (ValueError, AttributeError):
return False
def _deep_merge(base, override):
out = dict(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(out.get(key), dict):
out[key] = _deep_merge(out[key], value)
else:
out[key] = value
return out
class Store:
def __init__(self, folder):
self.folder = folder
self.snapshot_dir = os.path.join(folder, "snapshots")
self.room_dir = os.path.join(folder, "rooms")
os.makedirs(self.snapshot_dir, exist_ok=True)
os.makedirs(self.room_dir, exist_ok=True)
self._lock = threading.RLock()
self.secret = self._load_secret()
self.config = _deep_merge(DEFAULT_CONFIG, self._read("config.json", {}))
self._write("config.json", self.config)
self.users = self._read("users.json", {})
self.sessions = self._read("sessions.json", {})
self.guests = self._read("guests.json", {})
self._provision_admin()
self._prune_sessions()
# ----- file helpers -------------------------------------------------
def _path(self, name):
return os.path.join(self.folder, name)
def _read(self, name, default):
try:
with open(self._path(name), "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return default
except (OSError, ValueError) as e:
log.error(f"[RigShare] Could not read {name}: {e}")
return default
def _write(self, name, data):
with self._lock:
path = self._path(name)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path)
# ----- setup --------------------------------------------------------
def _load_secret(self):
"""JWT signing key, generated once and kept private to the server."""
path = self._path("secret.key")
try:
with open(path, "rb") as f:
secret = f.read()
if len(secret) >= 32:
return secret
except FileNotFoundError:
pass
secret = secrets.token_bytes(64)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(secret)
return secret
def _provision_admin(self):
"""Optional scripted install: create the first admin from env vars.
Otherwise there are no accounts until someone completes the first-run
setup on the login page.
"""
username = os.environ.get("RIGSHARE_ADMIN_USER")
password = os.environ.get("RIGSHARE_ADMIN_PASSWORD")
if self.users or not (username and password):
if not self.users:
log.warning("[RigShare] No accounts yet: open ComfyUI in a browser to create the admin account.")
return
self.create_user(username, password, perms={"edit": True, "queue": True, "admin": True})
log.info(f"[RigShare] Created admin '{username}' from environment variables.")
@property
def needs_setup(self):
return not self.users
def setup_admin(self, username, password, display_name=None):
"""First-run setup: only allowed while no account exists."""
with self._lock:
if self.users:
raise PermissionError("Setup is already complete")
return self.create_user(username, password, display_name, {"edit": True, "queue": True, "admin": True})
def _new_user(self, username, password, perms):
return {
"username": username,
"display_name": username,
"password": _hash_password(password),
"perms": {p: bool(perms.get(p)) for p in PERMS},
"created": int(time.time()),
}
def _prune_sessions(self):
now = time.time()
alive = {t: s for t, s in self.sessions.items() if s["expires"] > now and s["username"] in self.users}
if len(alive) != len(self.sessions):
self.sessions = alive
self._write("sessions.json", self.sessions)
# ----- config -------------------------------------------------------
def update_config(self, patch):
allowed = set(DEFAULT_CONFIG)
clean = {k: v for k, v in patch.items() if k in allowed}
self.config = _deep_merge(self.config, clean)
self._write("config.json", self.config)
return self.config
# ----- accounts -----------------------------------------------------
@staticmethod
def public_user(user):
return {
"username": user["username"],
"display_name": user.get("display_name") or user["username"],
"perms": user["perms"],
"created": user.get("created"),
}
def authenticate(self, username, password):
user = self.users.get(username)
if user and _verify_password(password, user["password"]):
return user
# Burn comparable time so unknown usernames are not distinguishable.
_verify_password(password, _hash_password("x", b"0" * 16))
return None
def create_user(self, username, password, display_name=None, perms=None):
if not USERNAME_RE.match(username or ""):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if username in self.users:
raise ValueError("Username already exists")
if len(password or "") < 8:
raise ValueError("Password must be at least 8 characters")
user = self._new_user(username, password, perms or {})
if display_name:
user["display_name"] = display_name[:32]
self.users[username] = user
self._write("users.json", self.users)
return user
def rename_user(self, username, new_username):
"""Change a login name; sessions and API keys follow the account."""
new_username = (new_username or "").strip()
if new_username == username:
return self.users[username]
if username not in self.users:
raise KeyError(username)
if not USERNAME_RE.match(new_username):
raise ValueError("Username must be 2-32 characters: letters, digits, _ . -")
if new_username in self.users:
raise ValueError("Username already exists")
with self._lock:
user = self.users.pop(username)
user["username"] = new_username
if user.get("display_name") in (None, "", username):
user["display_name"] = new_username
self.users[new_username] = user
for session in self.sessions.values():
if session["username"] == username:
session["username"] = new_username
self._write("users.json", self.users)
self._write("sessions.json", self.sessions)
return user
def update_user(self, username, display_name=None, perms=None, password=None):
user = self.users.get(username)
if not user:
raise KeyError(username)
if display_name is not None:
user["display_name"] = display_name.strip()[:32] or username
if perms is not None:
new_perms = {p: bool(perms.get(p, user["perms"].get(p))) for p in PERMS}
if user["perms"].get("admin") and not new_perms["admin"] and self._admin_count() <= 1:
raise ValueError("Cannot remove the last admin")
user["perms"] = new_perms
if password is not None:
if len(password) < 8:
raise ValueError("Password must be at least 8 characters")
user["password"] = _hash_password(password)
self.revoke_sessions(username)
self._write("users.json", self.users)
return user
def delete_user(self, username):
user = self.users.get(username)
if not user:
raise KeyError(username)
if user["perms"].get("admin") and self._admin_count() <= 1:
raise ValueError("Cannot delete the last admin")
del self.users[username]
self._write("users.json", self.users)
self.revoke_sessions(username)
def _admin_count(self):
return sum(1 for u in self.users.values() if u["perms"].get("admin"))
# ----- sessions -----------------------------------------------------
# Browser logins are JWTs (HS256, 12 h) that reference a server-side
# session id. The session makes logout, password changes and account
# deletion take effect immediately instead of waiting for expiry.
def _issue(self, sid, username):
now = int(time.time())
return jwt.encode({"iss": ISSUER, "sub": username, "sid": sid, "iat": now, "nbf": now,
"exp": now + TOKEN_TTL}, self.secret)
def create_session(self, username):
sid = secrets.token_urlsafe(16)
self.sessions[sid] = {"username": username, "expires": time.time() + SESSION_TTL, "created": int(time.time())}
self._write("sessions.json", self.sessions)
return self._issue(sid, username)
def _session_claims(self, token, verify_exp=True):
try:
claims = jwt.decode(token, self.secret, ISSUER, verify_exp=verify_exp)
except jwt.InvalidToken:
return None
session = self.sessions.get(claims.get("sid", ""))
if not session or session["expires"] < time.time():
return None
# The session (not the token) says who this is, so renames keep sessions valid.
return {**claims, "sub": session["username"]}
def user_for_token(self, token):
"""Resolve a session JWT or a personal API key to a user."""
if not token:
return None
if token.startswith(API_KEY_PREFIX):
return self.user_for_api_key(token)
claims = self._session_claims(token)
return self.users.get(claims["sub"]) if claims else None
def refresh_token(self, token):
"""Return a fresh JWT for the same session when this one is getting old."""
if not token or token.startswith(API_KEY_PREFIX):
return None
claims = self._session_claims(token)
if not claims or time.time() - claims.get("iat", 0) < TOKEN_REFRESH:
return None
return self._issue(claims["sid"], claims["sub"])
# ----- API keys -----------------------------------------------------
@staticmethod
def _key_hash(key):
return hashlib.sha256(key.encode()).hexdigest()
def list_api_keys(self, username):
user = self.users.get(username) or {}
return [{k: v for k, v in key.items() if k != "hash"} for key in user.get("api_keys", [])]
def create_api_key(self, username, name):
user = self.users[username]
key = API_KEY_PREFIX + secrets.token_urlsafe(32)
entry = {"id": secrets.token_hex(4), "name": (name or "API key").strip()[:40],
"hash": self._key_hash(key), "hint": key[:len(API_KEY_PREFIX) + 4] + "..." + key[-4:],
"created": int(time.time()), "last_used": None}
user.setdefault("api_keys", []).append(entry)
self._write("users.json", self.users)
return key, {k: v for k, v in entry.items() if k != "hash"}
def revoke_api_key(self, username, key_id):
user = self.users[username]
before = len(user.get("api_keys", []))
user["api_keys"] = [k for k in user.get("api_keys", []) if k["id"] != key_id]
if len(user["api_keys"]) != before:
self._write("users.json", self.users)
return True
return False
def user_for_api_key(self, key):
digest = self._key_hash(key)
for user in self.users.values():
for entry in user.get("api_keys", []):
if hmac.compare_digest(entry["hash"], digest):
now = int(time.time())
if not entry.get("last_used") or now - entry["last_used"] > 300:
entry["last_used"] = now
self._write("users.json", self.users)
return user
return None
def revoke_session(self, token):
claims = self._session_claims(token, verify_exp=False) if token else None
if claims and self.sessions.pop(claims["sid"], None):
self._write("sessions.json", self.sessions)
def revoke_sessions(self, username):
before = len(self.sessions)
self.sessions = {t: s for t, s in self.sessions.items() if s["username"] != username}
if len(self.sessions) != before:
self._write("sessions.json", self.sessions)
# ----- guests -------------------------------------------------------
def guest_perms(self, guest_id):
base = self.config["guest_permissions"]
override = self.guests.get(guest_id, {})
return {"edit": bool(override.get("edit", base.get("edit"))),
"queue": bool(override.get("queue", base.get("queue"))),
"admin": False}
def set_guest_perms(self, guest_id, perms):
entry = self.guests.setdefault(guest_id, {})
for p in ("edit", "queue"):
if p in perms:
entry[p] = bool(perms[p])
self._write("guests.json", self.guests)
# ----- rooms ----------------------------------------------------------
def _room_path(self, key):
return os.path.join(self.room_dir, hashlib.sha1(key.encode()).hexdigest()[:20] + ".json")
def load_rooms(self):
rooms = []
for name in os.listdir(self.room_dir):
if name.endswith(".json"):
try:
with open(os.path.join(self.room_dir, name), "r", encoding="utf-8") as f:
rooms.append(json.load(f))
except (OSError, ValueError):
pass
return rooms
def save_room(self, room):
path = self._room_path(room["key"])
with self._lock:
with open(path + ".tmp", "w", encoding="utf-8") as f:
json.dump(room, f)
os.replace(path + ".tmp", path)
def delete_room(self, key):
try:
os.remove(self._room_path(key))
except OSError:
pass
# ----- snapshots ------------------------------------------------------
def list_snapshots(self, room=None):
items = []
for name in sorted(os.listdir(self.snapshot_dir), reverse=True):
if not name.endswith(".json"):
continue
path = os.path.join(self.snapshot_dir, name)
try:
with open(path, "r", encoding="utf-8") as f:
meta = json.load(f).get("meta", {})
except (OSError, ValueError):
continue
if room is None or meta.get("room") == room:
items.append({"id": name[:-5], **meta})
return items
def add_snapshot(self, doc, author, room, room_name, label=None):
snap_id = time.strftime("%Y%m%d-%H%M%S") + "-" + secrets.token_hex(2)
meta = {
"time": int(time.time()),
"author": author,
"label": (label or "")[:80],
"room": room,
"room_name": room_name,
"nodes": len(doc.get("nodes", [])) if isinstance(doc, dict) else 0,
}
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "w", encoding="utf-8") as f:
json.dump({"meta": meta, "doc": doc}, f)
self._prune_snapshots(room)
return {"id": snap_id, **meta}
def get_snapshot(self, snap_id):
if not re.match(r"^[\w-]+$", snap_id or ""):
return None
try:
with open(os.path.join(self.snapshot_dir, snap_id + ".json"), "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def _prune_snapshots(self, room):
keep = max(1, int(self.config.get("snapshot_keep", 30)))
# Labelled (manual) snapshots are kept; only automatic ones rotate.
auto = [s for s in self.list_snapshots(room) if not s.get("label")]
for snap in auto[keep:]:
try:
os.remove(os.path.join(self.snapshot_dir, snap["id"] + ".json"))
except OSError:
pass